A WIP swift OAuth Library that one day I'll get back to
at main 315 lines 13 kB view raw
1// 2// OAuthClient.swift 3// Gulliver 4// 5// Created by Bailey Townsend on 1/20/26. 6// 7 8import Foundation 9import ATIdentityTools 10import ATCommonWeb 11import CryptoKit 12import Cache 13 14 15public class OAuthClient { 16 private let clientId: String 17 private let redirectUri: String 18 //Who knows, you might need to override this 19 public var clientUrlSession: URLSession 20 private var didResolver: DIDResolver 21 private var handleResolver: HandleResolver 22 23 24 public init( 25 clientId: String, 26 redirectUri: String, 27 clientUrlSession: URLSession = .shared, 28 didOptions: DIDResolverOptions? = nil, 29 didUrlSession: URLSession = .shared, 30 handleResolver: HandleResolver? = nil) { 31 32 self.didResolver = DIDResolver(options: didOptions, urlSession: didUrlSession) 33 if let handleResolver = handleResolver { 34 self.handleResolver = handleResolver 35 }else{ 36 self.handleResolver = HandleResolver() 37 } 38 39 self.clientId = clientId 40 self.redirectUri = redirectUri 41 self.clientUrlSession = clientUrlSession 42 } 43 44 45 /// Generic method to fetch and decode JSON from a URL 46 /// - Parameters: 47 /// - url: The URL to fetch from 48 /// - type: The Decodable type to decode the response into 49 /// - decoder: Optional custom JSONDecoder (defaults to standard JSONDecoder) 50 /// - Returns: The decoded object of type T 51 /// - Throws: OAuthClientError if the request fails or response is invalid 52 private func fetchJSON<T: Decodable>(from url: URL, as type: T.Type, decoder: JSONDecoder = JSONDecoder()) async throws -> T { 53 // Create request with proper headers 54 var request = URLRequest(url: url) 55 request.setValue("application/json", forHTTPHeaderField: "Accept") 56 57 // Perform the request 58 let (data, response) = try await clientUrlSession.data(for: request) 59 60 // Validate response 61 guard let httpResponse = response as? HTTPURLResponse else { 62 throw OAuthClientError.webRequestError("Invalid response type") 63 } 64 65 guard httpResponse.statusCode == 200 else { 66 throw OAuthClientError.webRequestError("Unexpected response status: \(httpResponse.statusCode)") 67 } 68 69 // Validate content type 70 guard let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type"), 71 contentType.lowercased().contains("application/json") else { 72 throw OAuthClientError.webRequestError("Unexpected content type") 73 } 74 75 // Decode the JSON 76 return try decoder.decode(T.self, from: data) 77 } 78 79 80 /// Gets the clientmetadata from the cliend ID 81 private func getClientMetadata(clientId: String) async throws -> OAuthClientMetadata { 82 // Construct the well-known URL 83 guard let url = URL(string: clientId)else{ 84 throw OAuthClientError.metaDatasError("Invalid client ID. Is not a URL") 85 } 86 87 // Fetch and decode the metadata using the generic method 88 return try await fetchJSON(from: url, as: OAuthClientMetadata.self) 89 90 } 91 92 /// Gets the protected metadata at /.well-known/oauth-protected-resource 93 private func getProtectedResourceMetadata(url: URL) async throws -> OAuthProtectedResourceMetadata { 94 // Construct the well-known URL 95 guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { 96 throw OAuthClientError.metaDatasError("Invalid URL for metadata server") 97 } 98 components.path = "/.well-known/oauth-protected-resource" 99 100 guard let metadataURL = components.url else { 101 throw OAuthClientError.metaDatasError("Failed to construct protected metadata URL") 102 } 103 104 // Fetch and decode the metadata using the generic method 105 let metadata = try await fetchJSON(from: metadataURL, as: OAuthProtectedResourceMetadata.self) 106 107 // Validate that the resource matches the origin 108 guard let origin = components.scheme.map({ "\($0)://\(components.host ?? "")" }), 109 metadata.resource.absoluteString.hasPrefix(origin) else { 110 throw OAuthClientError.metaDatasError("Unexpected resource identifier in metadata") 111 } 112 113 return metadata 114 } 115 116 /// Gets the protected metadata at /.well-known/oauth-authorization-server 117 private func getOAuthAuthorizationServerMetadata(url: URL) async throws -> OAuthAuthorizationServerMetadata { 118 // Construct the well-known URL 119 guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { 120 throw OAuthClientError.metaDatasError("Invalid URL for metadata server") 121 } 122 components.path = "/.well-known/oauth-authorization-server" 123 124 guard let metadataURL = components.url else { 125 throw OAuthClientError.metaDatasError("Failed to construct metadata URL") 126 } 127 128 // Fetch and decode the metadata using the generic method 129 return try await fetchJSON(from: metadataURL, as: OAuthAuthorizationServerMetadata.self) 130 131 } 132 133 134 /// Generic call that calls the abstracted endpoints to get the metadatas 135 private func getAuthMetadata(url: URL) async throws -> OAuthAuthorizationServerMetadata { 136 137 let protectedResourceMetadata = try await getProtectedResourceMetadata(url: url) 138 139 guard let authorizedServers = protectedResourceMetadata.authorizationServers else { 140 throw OAuthClientError.metaDatasError("No authorization servers found in protected metadata") 141 } 142 143 //Some manual checks atcute did 144 if authorizedServers.count != 1 { 145 throw OAuthClientError.metaDatasError("expected exactly one authorization server in the listing") 146 } 147 148 let issuer = authorizedServers.first 149 guard let issuerUrl = URL(string: issuer ?? "") else { 150 throw OAuthClientError.metaDatasError("Failed to parse issuer URL from protected metadata") 151 } 152 let metadata = try await self.getAuthMetadata(url: issuerUrl) 153 154 if let protectedResources = metadata.protectedResources { 155 if !protectedResources.contains(protectedResourceMetadata.resource) { 156 throw OAuthClientError.metaDatasError("server is not in authorization server's jurisdiction") 157 } 158 } 159 160 return metadata 161 } 162 163 164 /// Generates a random code verifier for PKCE. 165 /// - Returns: A base64url-encoded random string. 166 private func generateCodeVerifier() -> String { 167 let verifierData = Data((0 ..< 32).map { _ in UInt8.random(in: 0 ... 255) }) 168 return base64URLEncode(verifierData) 169 } 170 171 /// Generates a code challenge from a code verifier. 172 /// - Parameter verifier: The code verifier. 173 /// - Returns: The base64url-encoded SHA-256 hash of the verifier. 174 private func generateCodeChallenge(from verifier: String) -> String { 175 let verifierData = Data(verifier.utf8) 176 let hash = SHA256.hash(data: verifierData) 177 return base64URLEncode(Data(hash)) 178 } 179 180 func postPAR(to url: URL, parameters: [String: String], decoder: JSONDecoder = JSONDecoder()) async throws -> OAuthPushedAuthorizationResponse { 181 182 var request = URLRequest(url: url) 183 request.httpMethod = "POST" 184 request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 185 186 // Convert parameters to form-encoded string 187 let formString = parameters 188 .map { key, value in 189 let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key 190 let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value 191 return "\(encodedKey)=\(encodedValue)" 192 } 193 .joined(separator: "&") 194 195 request.httpBody = formString.data(using: .utf8) 196// request.setValue("\(dpop)", forHTTPHeaderField: "DPoP") 197 198 199 let (data, response) = try await URLSession.shared.data(for: request) 200 201 guard let httpResponse = response as? HTTPURLResponse, 202 (200...299).contains(httpResponse.statusCode) else { 203 throw URLError(.badServerResponse) 204 } 205 206 // Decode the JSON 207 return try decoder.decode(OAuthPushedAuthorizationResponse.self, from: data) 208 } 209 210 211 public func createAuthorizationURL(identifier: String, resourceUrl: URL? = nil) async throws -> URL { 212 do { 213 var pdsUrl: URL? = nil 214 var did: String? = nil 215 var didDoc: DIDDocument? = nil 216 217 218 if identifier.starts(with: "http") { 219 guard let url = URL(string: identifier) else { 220 throw OAuthClientError.identifierParsingFailed 221 } 222 pdsUrl = url 223 } 224 225 if pdsUrl == nil { 226 if identifier.starts(with: "did:") { 227 did = identifier 228 } 229 } 230 231 if did == nil && pdsUrl == nil { 232 did = try await handleResolver.resolve(handle: identifier) 233 let didDocument = try await didResolver.resolve(did: did!, willForceRefresh: false) 234 didDoc = didDocument 235 pdsUrl = didDocument.getPDSEndpoint() 236 } 237 238 if did == nil && pdsUrl == nil{ 239 throw OAuthClientError.identifierParsingFailed 240 } 241 242 243 244 if pdsUrl == nil { 245 throw OAuthClientError.catchAll("No PDS URL Found") 246 } 247 248 249 let oauthServerMetadata = try await getOAuthAuthorizationServerMetadata(url: resourceUrl ?? pdsUrl!) 250 251 let pckeCode = generateCodeVerifier() 252 let pcke = generateCodeChallenge(from: pckeCode) 253 254 let clientMetadata = try await getClientMetadata(clientId: self.clientId) 255 256 257 let sessionId = UUID().uuidString 258 259 let parameters: [String: String] = [ 260 "client_id": clientId, 261 "response_type": "code", 262 "state": sessionId, 263 "redirect_uri": self.redirectUri, 264 "scope": clientMetadata.scope ?? "", 265 "code_challenge": pcke, 266 "code_challenge_method": "S256", 267 "response_mode": "fragment", 268 "display": "page", 269 ] 270 271 272 guard let parEndpoint = oauthServerMetadata.pushedAuthorizationRequestEndpoint else { 273 throw OAuthClientError.catchAll("No Pushed Authorization Request Endpoint Found") 274 } 275 276 let dpopKey = try await createDPoPKey(for: identifier) 277 let stateKeyStore = getStateKeychainStore() 278 let signer = DPoPSigner(privateKey: dpopKey, keychainStore: stateKeyStore) 279 let requestSign = try signer.createProof(httpMethod: "POST", url: parEndpoint.absoluteString) 280 281 //PAR returns a dpop nonce i need to follow, and can also fail to a dpop needing the nonce 282 //resend with one sign then 283 let parResult = try await postPAR(to: parEndpoint, parameters: parameters) 284 285 guard var components = URLComponents(url: oauthServerMetadata.authorizationEndpoint, resolvingAgainstBaseURL: true) else { 286 throw OAuthClientError.catchAll("Failed to construct authorization URL") 287 } 288 289 let queryItems: [URLQueryItem] = [ 290 URLQueryItem(name: "client_id", value: clientId), 291 URLQueryItem(name: "request_uri", value: parResult.requestUri), 292 URLQueryItem(name: "redirect_uri", value: self.redirectUri), 293 294 ] 295 components.queryItems = (components.queryItems ?? []) + queryItems 296 297 guard let fullAuthUrl = components.url else { 298 throw OAuthClientError.catchAll("Failed to construct authorization URL") 299 } 300 301 return fullAuthUrl 302 } catch let error as OAuthClientError { 303 throw error 304 }catch { 305 throw OAuthClientError.unknownError(error) 306 } 307 308 309// public func callback(, resourceUrl: URL? = nil) async throws -> URL { 310// //Need to take the redirected url code and state 311// //Use the state/sessionId to load teh dpop key we started with and load it as the identity/did coming back 312// } 313 } 314} 315