// // OAuthClient.swift // Gulliver // // Created by Bailey Townsend on 1/20/26. // import Foundation import ATIdentityTools import ATCommonWeb import CryptoKit import Cache public class OAuthClient { private let clientId: String private let redirectUri: String //Who knows, you might need to override this public var clientUrlSession: URLSession private var didResolver: DIDResolver private var handleResolver: HandleResolver public init( clientId: String, redirectUri: String, clientUrlSession: URLSession = .shared, didOptions: DIDResolverOptions? = nil, didUrlSession: URLSession = .shared, handleResolver: HandleResolver? = nil) { self.didResolver = DIDResolver(options: didOptions, urlSession: didUrlSession) if let handleResolver = handleResolver { self.handleResolver = handleResolver }else{ self.handleResolver = HandleResolver() } self.clientId = clientId self.redirectUri = redirectUri self.clientUrlSession = clientUrlSession } /// Generic method to fetch and decode JSON from a URL /// - Parameters: /// - url: The URL to fetch from /// - type: The Decodable type to decode the response into /// - decoder: Optional custom JSONDecoder (defaults to standard JSONDecoder) /// - Returns: The decoded object of type T /// - Throws: OAuthClientError if the request fails or response is invalid private func fetchJSON(from url: URL, as type: T.Type, decoder: JSONDecoder = JSONDecoder()) async throws -> T { // Create request with proper headers var request = URLRequest(url: url) request.setValue("application/json", forHTTPHeaderField: "Accept") // Perform the request let (data, response) = try await clientUrlSession.data(for: request) // Validate response guard let httpResponse = response as? HTTPURLResponse else { throw OAuthClientError.webRequestError("Invalid response type") } guard httpResponse.statusCode == 200 else { throw OAuthClientError.webRequestError("Unexpected response status: \(httpResponse.statusCode)") } // Validate content type guard let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type"), contentType.lowercased().contains("application/json") else { throw OAuthClientError.webRequestError("Unexpected content type") } // Decode the JSON return try decoder.decode(T.self, from: data) } /// Gets the clientmetadata from the cliend ID private func getClientMetadata(clientId: String) async throws -> OAuthClientMetadata { // Construct the well-known URL guard let url = URL(string: clientId)else{ throw OAuthClientError.metaDatasError("Invalid client ID. Is not a URL") } // Fetch and decode the metadata using the generic method return try await fetchJSON(from: url, as: OAuthClientMetadata.self) } /// Gets the protected metadata at /.well-known/oauth-protected-resource private func getProtectedResourceMetadata(url: URL) async throws -> OAuthProtectedResourceMetadata { // Construct the well-known URL guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { throw OAuthClientError.metaDatasError("Invalid URL for metadata server") } components.path = "/.well-known/oauth-protected-resource" guard let metadataURL = components.url else { throw OAuthClientError.metaDatasError("Failed to construct protected metadata URL") } // Fetch and decode the metadata using the generic method let metadata = try await fetchJSON(from: metadataURL, as: OAuthProtectedResourceMetadata.self) // Validate that the resource matches the origin guard let origin = components.scheme.map({ "\($0)://\(components.host ?? "")" }), metadata.resource.absoluteString.hasPrefix(origin) else { throw OAuthClientError.metaDatasError("Unexpected resource identifier in metadata") } return metadata } /// Gets the protected metadata at /.well-known/oauth-authorization-server private func getOAuthAuthorizationServerMetadata(url: URL) async throws -> OAuthAuthorizationServerMetadata { // Construct the well-known URL guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { throw OAuthClientError.metaDatasError("Invalid URL for metadata server") } components.path = "/.well-known/oauth-authorization-server" guard let metadataURL = components.url else { throw OAuthClientError.metaDatasError("Failed to construct metadata URL") } // Fetch and decode the metadata using the generic method return try await fetchJSON(from: metadataURL, as: OAuthAuthorizationServerMetadata.self) } /// Generic call that calls the abstracted endpoints to get the metadatas private func getAuthMetadata(url: URL) async throws -> OAuthAuthorizationServerMetadata { let protectedResourceMetadata = try await getProtectedResourceMetadata(url: url) guard let authorizedServers = protectedResourceMetadata.authorizationServers else { throw OAuthClientError.metaDatasError("No authorization servers found in protected metadata") } //Some manual checks atcute did if authorizedServers.count != 1 { throw OAuthClientError.metaDatasError("expected exactly one authorization server in the listing") } let issuer = authorizedServers.first guard let issuerUrl = URL(string: issuer ?? "") else { throw OAuthClientError.metaDatasError("Failed to parse issuer URL from protected metadata") } let metadata = try await self.getAuthMetadata(url: issuerUrl) if let protectedResources = metadata.protectedResources { if !protectedResources.contains(protectedResourceMetadata.resource) { throw OAuthClientError.metaDatasError("server is not in authorization server's jurisdiction") } } return metadata } /// Generates a random code verifier for PKCE. /// - Returns: A base64url-encoded random string. private func generateCodeVerifier() -> String { let verifierData = Data((0 ..< 32).map { _ in UInt8.random(in: 0 ... 255) }) return base64URLEncode(verifierData) } /// Generates a code challenge from a code verifier. /// - Parameter verifier: The code verifier. /// - Returns: The base64url-encoded SHA-256 hash of the verifier. private func generateCodeChallenge(from verifier: String) -> String { let verifierData = Data(verifier.utf8) let hash = SHA256.hash(data: verifierData) return base64URLEncode(Data(hash)) } func postPAR(to url: URL, parameters: [String: String], decoder: JSONDecoder = JSONDecoder()) async throws -> OAuthPushedAuthorizationResponse { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") // Convert parameters to form-encoded string let formString = parameters .map { key, value in let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value return "\(encodedKey)=\(encodedValue)" } .joined(separator: "&") request.httpBody = formString.data(using: .utf8) // request.setValue("\(dpop)", forHTTPHeaderField: "DPoP") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { throw URLError(.badServerResponse) } // Decode the JSON return try decoder.decode(OAuthPushedAuthorizationResponse.self, from: data) } public func createAuthorizationURL(identifier: String, resourceUrl: URL? = nil) async throws -> URL { do { var pdsUrl: URL? = nil var did: String? = nil var didDoc: DIDDocument? = nil if identifier.starts(with: "http") { guard let url = URL(string: identifier) else { throw OAuthClientError.identifierParsingFailed } pdsUrl = url } if pdsUrl == nil { if identifier.starts(with: "did:") { did = identifier } } if did == nil && pdsUrl == nil { did = try await handleResolver.resolve(handle: identifier) let didDocument = try await didResolver.resolve(did: did!, willForceRefresh: false) didDoc = didDocument pdsUrl = didDocument.getPDSEndpoint() } if did == nil && pdsUrl == nil{ throw OAuthClientError.identifierParsingFailed } if pdsUrl == nil { throw OAuthClientError.catchAll("No PDS URL Found") } let oauthServerMetadata = try await getOAuthAuthorizationServerMetadata(url: resourceUrl ?? pdsUrl!) let pckeCode = generateCodeVerifier() let pcke = generateCodeChallenge(from: pckeCode) let clientMetadata = try await getClientMetadata(clientId: self.clientId) let sessionId = UUID().uuidString let parameters: [String: String] = [ "client_id": clientId, "response_type": "code", "state": sessionId, "redirect_uri": self.redirectUri, "scope": clientMetadata.scope ?? "", "code_challenge": pcke, "code_challenge_method": "S256", "response_mode": "fragment", "display": "page", ] guard let parEndpoint = oauthServerMetadata.pushedAuthorizationRequestEndpoint else { throw OAuthClientError.catchAll("No Pushed Authorization Request Endpoint Found") } let dpopKey = try await createDPoPKey(for: identifier) let stateKeyStore = getStateKeychainStore() let signer = DPoPSigner(privateKey: dpopKey, keychainStore: stateKeyStore) let requestSign = try signer.createProof(httpMethod: "POST", url: parEndpoint.absoluteString) //PAR returns a dpop nonce i need to follow, and can also fail to a dpop needing the nonce //resend with one sign then let parResult = try await postPAR(to: parEndpoint, parameters: parameters) guard var components = URLComponents(url: oauthServerMetadata.authorizationEndpoint, resolvingAgainstBaseURL: true) else { throw OAuthClientError.catchAll("Failed to construct authorization URL") } let queryItems: [URLQueryItem] = [ URLQueryItem(name: "client_id", value: clientId), URLQueryItem(name: "request_uri", value: parResult.requestUri), URLQueryItem(name: "redirect_uri", value: self.redirectUri), ] components.queryItems = (components.queryItems ?? []) + queryItems guard let fullAuthUrl = components.url else { throw OAuthClientError.catchAll("Failed to construct authorization URL") } return fullAuthUrl } catch let error as OAuthClientError { throw error }catch { throw OAuthClientError.unknownError(error) } // public func callback(, resourceUrl: URL? = nil) async throws -> URL { // //Need to take the redirected url code and state // //Use the state/sessionId to load teh dpop key we started with and load it as the identity/did coming back // } } }