A WIP swift OAuth Library that one day I'll get back to

wip

+1667 -2
+114
Package.resolved
··· 1 + { 2 + "originHash" : "3d4b19d0d29ba6128784ecc8a7fba8fe30ec136ee8416203f01fcfd9d0c93aa3", 3 + "pins" : [ 4 + { 5 + "identity" : "atcommontools", 6 + "kind" : "remoteSourceControl", 7 + "location" : "https://github.com/ATProtoKit/ATCommonTools.git", 8 + "state" : { 9 + "revision" : "f37c1b617a6377bf2d17c225cf05880e7d30db9d", 10 + "version" : "0.0.15" 11 + } 12 + }, 13 + { 14 + "identity" : "atcryptography", 15 + "kind" : "remoteSourceControl", 16 + "location" : "https://github.com/ATProtoKit/ATCryptography.git", 17 + "state" : { 18 + "revision" : "b1b69232a4d980d55e4566acb5455f37397c2f60", 19 + "version" : "0.2.0" 20 + } 21 + }, 22 + { 23 + "identity" : "atidentitytools", 24 + "kind" : "remoteSourceControl", 25 + "location" : "https://github.com/fatfingers23/ATIdentityTools.git", 26 + "state" : { 27 + "branch" : "main", 28 + "revision" : "7c2b9b25a2ffdad6e454c2f177fc4a681d5c48f2" 29 + } 30 + }, 31 + { 32 + "identity" : "bigint", 33 + "kind" : "remoteSourceControl", 34 + "location" : "https://github.com/attaswift/BigInt.git", 35 + "state" : { 36 + "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", 37 + "version" : "5.7.0" 38 + } 39 + }, 40 + { 41 + "identity" : "cache", 42 + "kind" : "remoteSourceControl", 43 + "location" : "https://github.com/hyperoslo/Cache.git", 44 + "state" : { 45 + "revision" : "24e47109e31b2031cb26e25cc1b81b607496066c", 46 + "version" : "7.4.0" 47 + } 48 + }, 49 + { 50 + "identity" : "joseswift", 51 + "kind" : "remoteSourceControl", 52 + "location" : "https://github.com/airsidemobile/JOSESwift.git", 53 + "state" : { 54 + "revision" : "c2664a902e75c0426a1d43132bd4babc6fd173d3", 55 + "version" : "3.0.0" 56 + } 57 + }, 58 + { 59 + "identity" : "multiformatskit", 60 + "kind" : "remoteSourceControl", 61 + "location" : "https://github.com/ATProtoKit/MultiformatsKit.git", 62 + "state" : { 63 + "revision" : "e03ab44983ae3cf525a3f8df8d1640f819385926", 64 + "version" : "0.3.0" 65 + } 66 + }, 67 + { 68 + "identity" : "swift-asn1", 69 + "kind" : "remoteSourceControl", 70 + "location" : "https://github.com/apple/swift-asn1.git", 71 + "state" : { 72 + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", 73 + "version" : "1.5.1" 74 + } 75 + }, 76 + { 77 + "identity" : "swift-async-dns-resolver", 78 + "kind" : "remoteSourceControl", 79 + "location" : "https://github.com/apple/swift-async-dns-resolver", 80 + "state" : { 81 + "revision" : "2836c57c4c7c784569331cc35a4fe18941f6c796", 82 + "version" : "0.5.0" 83 + } 84 + }, 85 + { 86 + "identity" : "swift-cbor", 87 + "kind" : "remoteSourceControl", 88 + "location" : "https://github.com/MasterJ93/swift-cbor.git", 89 + "state" : { 90 + "revision" : "7452e1d1dbfb99fa0baca7732cf199be1bfc7e7c", 91 + "version" : "0.0.8" 92 + } 93 + }, 94 + { 95 + "identity" : "swift-crypto", 96 + "kind" : "remoteSourceControl", 97 + "location" : "https://github.com/apple/swift-crypto.git", 98 + "state" : { 99 + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", 100 + "version" : "3.15.1" 101 + } 102 + }, 103 + { 104 + "identity" : "swift-secp256k1", 105 + "kind" : "remoteSourceControl", 106 + "location" : "https://github.com/21-DOT-DEV/swift-secp256k1.git", 107 + "state" : { 108 + "revision" : "57ce9af6db14e0114af631ace25231a9d0ccccbd", 109 + "version" : "0.18.0" 110 + } 111 + } 112 + ], 113 + "version" : 3 114 + }
+21 -2
Package.swift
··· 5 5 6 6 let package = Package( 7 7 name: "Gulliver", 8 + platforms: [ 9 + .iOS(.v14), 10 + .macOS(.v13), 11 + .tvOS(.v14), 12 + .visionOS(.v1), 13 + .watchOS(.v9) 14 + ], 8 15 products: [ 9 16 // Products define the executables and libraries a package produces, making them visible to other packages. 10 17 .library( ··· 12 19 targets: ["Gulliver"] 13 20 ), 14 21 ], 22 + dependencies: [ 23 + .package(url: "https://github.com/fatfingers23/ATIdentityTools.git", branch: "main"), 24 + // .package(path: "../ATIdentityTools"), 25 + .package(url: "https://github.com/hyperoslo/Cache.git", .upToNextMajor(from: "7.4.0")), 26 + .package(url: "https://github.com/airsidemobile/JOSESwift.git", from: "3.0.0") 27 + ], 15 28 targets: [ 16 29 // Targets are the basic building blocks of a package, defining a module or a test suite. 17 30 // Targets can depend on other targets in this package and products from dependencies. 18 31 .target( 19 - name: "Gulliver" 32 + name: "Gulliver", 33 + dependencies: [ 34 + .product(name: "ATIdentityTools", package: "atidentitytools"), 35 + .product(name: "Cache", package: "Cache"), 36 + // "jose-swift" 37 + "JOSESwift" 38 + ] 20 39 ), 21 - 40 + 22 41 ] 23 42 )
+150
Sources/Gulliver/ATProtoClient.swift
··· 1 + // 2 + // ATProtoClient.swift 3 + // Gulliver 4 + // 5 + // Created by Bailey Townsend on 1/21/26. 6 + // 7 + 8 + import Foundation 9 + 10 + actor ATProtoClient { 11 + private var accessToken: String? 12 + private var refreshToken: String? 13 + private var dpopNonce: String? 14 + private let session: URLSession 15 + private let sessionId: String? 16 + private let keychainStore: KeychainStorage 17 + private let dpopSigner: DPoPSigner 18 + 19 + enum ATProtoError: Error { 20 + case unauthorized 21 + case dpopNonceRequired(nonce: String?) 22 + case maxRetriesExceeded 23 + case networkError(Error) 24 + } 25 + 26 + init(sessionId: String, session: URLSession = .shared) throws { 27 + self.sessionId = sessionId 28 + self.session = session 29 + //We will always use the session keychain here since the state is used else where 30 + self.keychainStore = getSessionKeychainStore() 31 + let dpopKey = try self.keychainStore.retrieveDPoPKey(keyTag: sessionId) 32 + self.dpopSigner = DPoPSigner(privateKey: dpopKey, keychainStore: self.keychainStore) 33 + } 34 + 35 + 36 + func request<T: Decodable & Sendable>( 37 + _ endpoint: String, 38 + method: String = "GET", 39 + body: Data? = nil, 40 + maxRetries: Int = 3 41 + ) async throws -> T { 42 + try await Task.retrying( 43 + maxRetryCount: maxRetries, 44 + retryDelay: 0.5, // Shorter delay for API retries 45 + operation: { 46 + try await self.performRequest(endpoint, method: method, body: body) 47 + }, 48 + onRetry: { [weak self] error, attempt in 49 + guard let self else { return } 50 + 51 + switch error { 52 + case let atError as ATProtoError: 53 + switch atError { 54 + case .dpopNonceRequired(let nonce): 55 + // Nonce already updated, just retry 56 + print("Retrying with DPoP nonce (attempt \(attempt + 1))") 57 + 58 + case .unauthorized: 59 + // Try to refresh token 60 + print("Refreshing access token (attempt \(attempt + 1))") 61 + try await self.refreshAccessToken() 62 + 63 + default: 64 + throw error // Don't retry other errors 65 + } 66 + 67 + default: 68 + throw error // Don't retry unknown errors 69 + } 70 + } 71 + ).value 72 + } 73 + 74 + private func performRequest<T: Decodable>( 75 + _ endpoint: String, 76 + method: String, 77 + body: Data? 78 + ) async throws -> T { 79 + guard let url = URL(string: endpoint) else { 80 + throw URLError(.badURL) 81 + } 82 + 83 + var request = URLRequest(url: url) 84 + request.httpMethod = method 85 + request.httpBody = body 86 + 87 + if let accessToken { 88 + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 89 + } 90 + 91 + let dpopProof = try self.dpopSigner.createProof(httpMethod: method, url: endpoint.lowercased()) 92 + 93 + 94 + request.setValue(dpopProof, forHTTPHeaderField: "DPoP") 95 + 96 + let (data, response) = try await session.data(for: request) 97 + 98 + guard let httpResponse = response as? HTTPURLResponse else { 99 + throw URLError(.badServerResponse) 100 + } 101 + 102 + // Always capture the nonce if present 103 + if let newNonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") { 104 + //save to cache now 105 + dpopNonce = newNonce 106 + } 107 + 108 + //TODO maybe abstract this out to be used? 109 + // Handle error cases 110 + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { 111 + if let dpopNonce = httpResponse.value(forHTTPHeaderField: "dpop-nonce") 112 + { 113 + throw ATProtoError.dpopNonceRequired(nonce: dpopNonce) 114 + } 115 + throw ATProtoError.unauthorized 116 + } 117 + 118 + guard (200...299).contains(httpResponse.statusCode) else { 119 + throw URLError(.badServerResponse) 120 + } 121 + 122 + return try JSONDecoder().decode(T.self, from: data) 123 + } 124 + 125 + private func refreshAccessToken() async throws { 126 + guard let refreshToken else { 127 + throw ATProtoError.unauthorized 128 + } 129 + 130 + struct RefreshResponse: Decodable { 131 + let accessJwt: String 132 + let refreshJwt: String 133 + } 134 + 135 + let body = try JSONEncoder().encode(["refreshToken": refreshToken]) 136 + 137 + // Refresh can also require DPoP retries, so it goes through request() 138 + let response: RefreshResponse = try await request( 139 + "https://your-pds.host/xrpc/com.atproto.server.refreshSession", 140 + method: "POST", 141 + body: body, 142 + maxRetries: 2 // Fewer retries for refresh 143 + ) 144 + 145 + accessToken = response.accessJwt 146 + self.refreshToken = response.refreshJwt 147 + } 148 + 149 + 150 + }
+176
Sources/Gulliver/DPoP.swift
··· 1 + // 2 + // dpop.swift 3 + // Gulliver 4 + // 5 + // Created by Bailey Townsend on 1/20/26. 6 + // 7 + 8 + import Foundation 9 + import CryptoKit 10 + import JOSESwift 11 + 12 + 13 + /// DPoP Signer for creating dpop+jwt proofs with P-256 keys 14 + public struct DPoPSigner { 15 + private let privateKey: P256.Signing.PrivateKey 16 + /// This can either be the state or session keychain depending if it's doing par or not 17 + private let keychainStore: KeychainStorage 18 + 19 + 20 + public init(privateKey: P256.Signing.PrivateKey, keychainStore: KeychainStorage) { 21 + self.privateKey = privateKey 22 + self.keychainStore = keychainStore 23 + } 24 + 25 + //TODO 26 + // Save state/session metadata is saved to store via store methods, but dpop keys are stored by key thing 27 + // the dpop keys are saved by sessionid or 28 + // Also need to check on those save and delete cause they dont follow the namespace? 29 + 30 + 31 + /// Session id is either the state key or the session key 32 + public func saveCurrentDpopKey(sessionId: String) throws { 33 + try keychainStore.storeDPoPKey(self.privateKey, keyTag: sessionId ) 34 + } 35 + 36 + /// Session id is either the state key or the session key 37 + 38 + public func deleteCurrentDpopKey(sessionId: String) throws { 39 + try keychainStore.deleteDPoPKey(keyTag: sessionId) 40 + } 41 + 42 + /// Creates a DPoP proof JWT 43 + /// - Parameters: 44 + /// - httpMethod: The HTTP method of the request (e.g., "POST", "GET") 45 + /// - url: The URL of the request (query and fragment will be stripped) 46 + /// - accessToken: Optional access token for resource access (will be hashed as 'ath' claim) 47 + /// - nonce: Optional server-provided nonce 48 + /// - Returns: A DPoP proof JWT string 49 + public func createProof( 50 + httpMethod: String, 51 + url: String, 52 + accessToken: String? = nil, 53 + nonce: String? = nil 54 + ) throws -> String { 55 + // Build the JWK for the public key 56 + let jwkDict = try createJWKDictionary() 57 + 58 + // Build header 59 + let headerDict: [String: Any] = [ 60 + "typ": "dpop+jwt", 61 + "alg": "ES256", 62 + "jwk": jwkDict 63 + ] 64 + 65 + // Build claims 66 + let jti = UUID().uuidString 67 + let iat = Int(Date().timeIntervalSince1970) 68 + let htu = sanitizeURL(url) 69 + 70 + var claimsDict: [String: Any] = [ 71 + "jti": jti, 72 + "htm": httpMethod.uppercased(), 73 + "htu": htu, 74 + "iat": iat 75 + ] 76 + 77 + // Add nonce if provided 78 + if let nonce = nonce { 79 + claimsDict["nonce"] = nonce 80 + } 81 + 82 + // Add access token hash if provided (for resource access) 83 + if let accessToken = accessToken { 84 + let ath = computeAccessTokenHash(accessToken) 85 + claimsDict["ath"] = ath 86 + } 87 + 88 + // Create the JWT 89 + let headerData = try JSONSerialization.data(withJSONObject: headerDict) 90 + let claimsData = try JSONSerialization.data(withJSONObject: claimsDict) 91 + 92 + let headerBase64 = base64URLEncode(headerData) 93 + let claimsBase64 = base64URLEncode(claimsData) 94 + 95 + let signingInput = "\(headerBase64).\(claimsBase64)" 96 + 97 + guard let signingData = signingInput.data(using: .utf8) else { 98 + throw DPoPError.encodingFailed 99 + } 100 + 101 + // Sign with ES256 (P-256 + SHA-256) 102 + let signature = try privateKey.signature(for: signingData) 103 + let signatureBase64 = base64URLEncode(signature.rawRepresentation) 104 + 105 + return "\(signingInput).\(signatureBase64)" 106 + } 107 + 108 + /// Creates a JWK dictionary representation of the public key 109 + private func createJWKDictionary() throws -> [String: String] { 110 + let publicKey = privateKey.publicKey 111 + let rawRepresentation = publicKey.rawRepresentation 112 + 113 + // P-256 raw representation is 64 bytes: 32 bytes for x, 32 bytes for y 114 + guard rawRepresentation.count == 64 else { 115 + throw DPoPError.invalidKeyFormat 116 + } 117 + 118 + let x = rawRepresentation.prefix(32) 119 + let y = rawRepresentation.suffix(32) 120 + 121 + return [ 122 + "kty": "EC", 123 + "crv": "P-256", 124 + "x": base64URLEncode(Data(x)), 125 + "y": base64URLEncode(Data(y)) 126 + ] 127 + } 128 + 129 + /// Sanitizes URL by removing query and fragment 130 + private func sanitizeURL(_ urlString: String) -> String { 131 + guard let url = URL(string: urlString), 132 + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { 133 + return urlString 134 + } 135 + components.query = nil 136 + components.fragment = nil 137 + return components.string ?? urlString 138 + } 139 + 140 + /// Computes the access token hash (ath) using SHA-256 141 + private func computeAccessTokenHash(_ accessToken: String) -> String { 142 + guard let tokenData = accessToken.data(using: .utf8) else { 143 + return "" 144 + } 145 + let hash = SHA256.hash(data: tokenData) 146 + return base64URLEncode(Data(hash)) 147 + } 148 + 149 + 150 + /// Returns the public key JWK as a JSON string (useful for client metadata) 151 + public func publicKeyJWK() throws -> String { 152 + let jwkDict = try createJWKDictionary() 153 + let data = try JSONSerialization.data(withJSONObject: jwkDict, options: .sortedKeys) 154 + return String(data: data, encoding: .utf8) ?? "" 155 + } 156 + } 157 + 158 + /// Errors that can occur during DPoP operations 159 + public enum DPoPError: Error { 160 + case encodingFailed 161 + case invalidKeyFormat 162 + case signingFailed 163 + } 164 + 165 + // MARK: - Keychain Storage Helpers 166 + 167 + func createDPoPKey(for sessionId: String) async throws -> P256.Signing.PrivateKey { 168 + let newKey = P256.Signing.PrivateKey() 169 + //Will always want the state store when creating a new key chain. transfer it over during login 170 + let storage = getStateKeychainStore() 171 + try await storage.storeDPoPKey(newKey, keyTag: sessionId) 172 + return newKey 173 + } 174 + 175 + 176 +
+44
Sources/Gulliver/Errors.swift
··· 1 + // 2 + // File.swift 3 + // Gulliver 4 + // 5 + // Created by Bailey Townsend on 1/20/26. 6 + // 7 + 8 + import Foundation 9 + 10 + 11 + extension OAuthClientError: LocalizedError { 12 + var errorDescription: String? { 13 + switch self { 14 + case .identifierParsingFailed: 15 + return "Failed to parse the identifier as a did, handle, or PDS URL." 16 + case .couldNotResolveADID: 17 + return "Could not resolve a DID for the provided input. Is this a valid handle or DID?" 18 + case .noPdsFound: 19 + return "No PDS endpoint was found for the provided identifier." 20 + case .unknownError(let error): 21 + let error = error as NSError 22 + let message = error.localizedDescription.isEmpty ? "An unknown error occurred." : error.localizedDescription 23 + return message 24 + case .webRequestError(let message): 25 + return message 26 + case .metaDatasError(let message): 27 + return message ?? "An unknown error occurred trying to get or parse the metadata from the resource server." 28 + case .catchAll(let message): 29 + return message 30 + } 31 + } 32 + } 33 + 34 + 35 + enum OAuthClientError: Error { 36 + case identifierParsingFailed 37 + case couldNotResolveADID 38 + case noPdsFound 39 + case unknownError(Error) 40 + case webRequestError(String) 41 + case metaDatasError(String?) 42 + case catchAll(String) 43 + 44 + }
+323
Sources/Gulliver/KeychainStorage.swift
··· 1 + // 2 + // File.swift 3 + // Gulliver 4 + // 5 + // Created by Bailey Townsend on 1/20/26. 6 + // 7 + 8 + import Foundation 9 + import CryptoKit 10 + 11 + enum SecureStore: String { 12 + case state = "state" 13 + case dpop = "dpop" 14 + } 15 + 16 + 17 + 18 + public func getStateKeychainStore() -> KeychainStorage { 19 + return KeychainStorage(namespace: "state") 20 + } 21 + 22 + public func getSessionKeychainStore() -> KeychainStorage { 23 + return KeychainStorage(namespace: "session") 24 + } 25 + 26 + 27 + 28 + public class KeychainStorage { 29 + let namespace: String 30 + private let accessGroup: String? 31 + 32 + private static var defaultAccessibility: CFString { 33 + #if os(iOS) 34 + return kSecAttrAccessibleAfterFirstUnlock 35 + #elseif os(macOS) 36 + return kSecAttrAccessibleAfterFirstUnlock 37 + #endif 38 + } 39 + 40 + private static func platformSpecificAttributes() -> [String: Any] { 41 + var attributes: [String: Any] = [:] 42 + 43 + #if os(macOS) 44 + // Disable iCloud sync for app-specific keychain items on macOS 45 + attributes[kSecAttrSynchronizable as String] = false 46 + #endif 47 + 48 + return attributes 49 + } 50 + 51 + 52 + public init(namespace: String = "default", accessGroup: String? = nil) { 53 + self.namespace = namespace 54 + self.accessGroup = accessGroup 55 + } 56 + 57 + private func getKey(did: String, store: SecureStore ) -> String { 58 + "\(did):\(store)" 59 + } 60 + 61 + 62 + 63 + func store(key: String, value: Data, namespace: String) throws { 64 + let namespacedKey = "\(namespace):\(key)" 65 + 66 + var query: [String: Any] = [ 67 + kSecClass as String: kSecClassGenericPassword, 68 + kSecAttrAccount as String: namespacedKey, 69 + kSecValueData as String: value, 70 + kSecAttrAccessible as String: Self.defaultAccessibility, 71 + ] 72 + 73 + // Add platform-specific attributes 74 + query.merge(Self.platformSpecificAttributes()) { _, new in new } 75 + 76 + // Delete any existing item with the same key 77 + let deleteStatus = SecItemDelete(query as CFDictionary) 78 + if deleteStatus != errSecSuccess, deleteStatus != errSecItemNotFound { 79 + throw KeychainStorageError.deleteError(Int(deleteStatus)) 80 + } 81 + 82 + // Add the new item to the keychain 83 + let status = SecItemAdd(query as CFDictionary, nil) 84 + if status == errSecDuplicateItem { 85 + throw KeychainStorageError.keyStoreError(Int(status)) 86 + } 87 + guard status == errSecSuccess else { 88 + throw KeychainStorageError.keyStoreError(Int(status)) 89 + } 90 + } 91 + 92 + func retrieve(key: String, namespace: String) throws -> Data { 93 + let namespacedKey = "\(namespace):\(key)" 94 + 95 + let query: [String: Any] = [ 96 + kSecClass as String: kSecClassGenericPassword, 97 + kSecAttrAccount as String: namespacedKey, 98 + kSecReturnData as String: kCFBooleanTrue!, 99 + kSecMatchLimit as String: kSecMatchLimitOne, 100 + ] 101 + 102 + var item: CFTypeRef? 103 + let status = SecItemCopyMatching(query as CFDictionary, &item) 104 + 105 + if status == errSecItemNotFound { 106 + throw KeychainStorageError.retrieveError(Int(status)) 107 + } 108 + 109 + guard status == errSecSuccess else { 110 + throw KeychainStorageError.retrieveError(Int(status)) 111 + } 112 + guard let data = item as? Data else { 113 + throw KeychainStorageError.dataFormatError 114 + } 115 + 116 + return data 117 + } 118 + 119 + func delete(key: String, namespace: String) throws { 120 + let namespacedKey = "\(namespace):\(key)" 121 + 122 + 123 + let query: [String: Any] = [ 124 + kSecClass as String: kSecClassGenericPassword, 125 + kSecAttrAccount as String: namespacedKey, 126 + ] 127 + 128 + let status = SecItemDelete(query as CFDictionary) 129 + 130 + if status != errSecSuccess, status != errSecItemNotFound { 131 + throw KeychainStorageError.deleteError(Int(status)) 132 + } 133 + } 134 + 135 + func deleteAll(namespace: String) throws { 136 + // Handle generic passwords first 137 + let genericSuccess = try deleteGenericPasswords(withNamespacePrefix: namespace) 138 + 139 + // Then handle crypto keys 140 + let keysSuccess = try deleteCryptoKeys(withNamespacePrefix: namespace) 141 + 142 + guard genericSuccess, keysSuccess else { 143 + throw KeychainStorageError.deleteError(-1) 144 + } 145 + } 146 + 147 + private func deleteGenericPasswords(withNamespacePrefix namespace: String) throws -> Bool { 148 + // Query to get all generic passwords 149 + let query: [String: Any] = [ 150 + kSecClass as String: kSecClassGenericPassword, 151 + kSecMatchLimit as String: kSecMatchLimitAll, 152 + kSecReturnAttributes as String: true, 153 + ] 154 + 155 + var result: AnyObject? 156 + let status = SecItemCopyMatching(query as CFDictionary, &result) 157 + 158 + if status == errSecSuccess, let items = result as? [[String: Any]] { 159 + var allSucceeded = true 160 + var matchedCount = 0 161 + 162 + // Filter and delete items that match our namespace 163 + for item in items { 164 + if let account = item[kSecAttrAccount as String] as? String, 165 + account.hasPrefix("\(namespace):") 166 + { 167 + matchedCount += 1 168 + 169 + let deleteQuery: [String: Any] = [ 170 + kSecClass as String: kSecClassGenericPassword, 171 + kSecAttrAccount as String: account, 172 + ] 173 + 174 + let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) 175 + if deleteStatus != errSecSuccess { 176 + allSucceeded = false 177 + } 178 + } 179 + } 180 + 181 + return allSucceeded 182 + } else if status == errSecItemNotFound { 183 + 184 + return true 185 + } else { 186 + //TODO throw or just sliently fail? 187 + return false 188 + } 189 + } 190 + 191 + private func deleteCryptoKeys(withNamespacePrefix namespace: String) throws -> Bool { 192 + // Query to get all keys 193 + let query: [String: Any] = [ 194 + kSecClass as String: kSecClassKey, 195 + kSecMatchLimit as String: kSecMatchLimitAll, 196 + kSecReturnAttributes as String: true, 197 + ] 198 + 199 + var result: AnyObject? 200 + let status = SecItemCopyMatching(query as CFDictionary, &result) 201 + 202 + if status == errSecSuccess, let items = result as? [[String: Any]] { 203 + var allSucceeded = true 204 + var matchedCount = 0 205 + 206 + // Filter and delete keys 207 + for item in items { 208 + // For keys, check the application tag 209 + if let tagData = item[kSecAttrApplicationTag as String] as? Data, 210 + let tagString = String(data: tagData, encoding: .utf8), 211 + tagString.hasPrefix("\(namespace).") 212 + { 213 + matchedCount += 1 214 + 215 + let deleteQuery: [String: Any] = [ 216 + kSecClass as String: kSecClassKey, 217 + kSecAttrApplicationTag as String: tagData, 218 + ] 219 + 220 + let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) 221 + if deleteStatus != errSecSuccess { 222 + allSucceeded = false 223 + } 224 + } 225 + } 226 + 227 + return allSucceeded 228 + } else if status == errSecItemNotFound { 229 + return true 230 + } else { 231 + return false 232 + } 233 + } 234 + 235 + func storeDPoPKey(_ key: P256.Signing.PrivateKey, keyTag: String) throws { 236 + guard let tagData = keyTag.data(using: .utf8) else { 237 + throw KeychainStorageError.dataFormatError 238 + } 239 + 240 + var query: [String: Any] = [ 241 + kSecClass as String: kSecClassKey, 242 + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, 243 + kSecAttrKeySizeInBits as String: 256, 244 + kSecAttrApplicationTag as String: tagData, 245 + kSecValueData as String: key.x963Representation, 246 + kSecAttrAccessible as String: Self.defaultAccessibility, 247 + ] 248 + 249 + // Delete any existing key first 250 + let deleteQuery: [String: Any] = [ 251 + kSecClass as String: kSecClassKey, 252 + kSecAttrApplicationTag as String: tagData, 253 + ] 254 + 255 + let deleteStatus = SecItemDelete(deleteQuery as CFDictionary) 256 + 257 + // Add the new key 258 + let status = SecItemAdd(query as CFDictionary, nil) 259 + if status == errSecDuplicateItem { 260 + // Try update 261 + let updateAttributes: [String: Any] = [ 262 + kSecValueData as String: key.x963Representation, 263 + ] 264 + let updateStatus = SecItemUpdate(deleteQuery as CFDictionary, updateAttributes as CFDictionary) 265 + guard updateStatus == errSecSuccess else { 266 + throw KeychainStorageError.keyStoreError(Int(updateStatus)) 267 + } 268 + } else if status != errSecSuccess { 269 + throw KeychainStorageError.keyStoreError(Int(status)) 270 + } 271 + } 272 + 273 + func retrieveDPoPKey(keyTag: String) throws -> P256.Signing.PrivateKey { 274 + guard let tagData = keyTag.data(using: .utf8) else { 275 + throw KeychainStorageError.dataFormatError 276 + } 277 + 278 + let query: [String: Any] = [ 279 + kSecClass as String: kSecClassKey, 280 + kSecAttrApplicationTag as String: tagData, 281 + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, 282 + kSecReturnData as String: kCFBooleanTrue!, 283 + kSecMatchLimit as String: kSecMatchLimitOne, 284 + ] 285 + 286 + var item: CFTypeRef? 287 + let status = SecItemCopyMatching(query as CFDictionary, &item) 288 + 289 + guard status == errSecSuccess, let data = item as? Data else { 290 + throw KeychainStorageError.retrieveError(Int(status)) 291 + } 292 + 293 + return try P256.Signing.PrivateKey(x963Representation: data) 294 + 295 + } 296 + 297 + func deleteDPoPKey(keyTag: String) throws { 298 + guard let tagData = keyTag.data(using: .utf8) else { 299 + throw KeychainStorageError.dataFormatError 300 + } 301 + 302 + let query: [String: Any] = [ 303 + kSecClass as String: kSecClassKey, 304 + kSecAttrApplicationTag as String: tagData, 305 + ] 306 + 307 + let status = SecItemDelete(query as CFDictionary) 308 + if status != errSecSuccess, status != errSecItemNotFound { 309 + throw KeychainStorageError.deleteError(Int(status)) 310 + } 311 + } 312 + 313 + } 314 + 315 + 316 + 317 + public enum KeychainStorageError: Error { 318 + case deleteError(Int) 319 + case keyStoreError(Int) 320 + case retrieveError(Int) 321 + case dataFormatError 322 + 323 + }
+315
Sources/Gulliver/OAuthClient.swift
··· 1 + // 2 + // OAuthClient.swift 3 + // Gulliver 4 + // 5 + // Created by Bailey Townsend on 1/20/26. 6 + // 7 + 8 + import Foundation 9 + import ATIdentityTools 10 + import ATCommonWeb 11 + import CryptoKit 12 + import Cache 13 + 14 + 15 + public 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 +
+476
Sources/Gulliver/OAuthTypes.swift
··· 1 + // 2 + // File.swift 3 + // Gulliver 4 + // 5 + // Created by Bailey Townsend on 1/20/26. 6 + // 7 + 8 + import Foundation 9 + 10 + 11 + 12 + /// OAuth 2.0 Client Metadata 13 + /// Based on RFC 7591 Section 2 and related specifications 14 + struct OAuthClientMetadata: Codable { 15 + /// REQUIRED. Array of redirection URIs for use in redirect-based flows 16 + let redirectUris: [URL] 17 + 18 + /// OPTIONAL. Array of OAuth 2.0 response_type values that the client will restrict itself to using 19 + let responseTypes: [String]? 20 + 21 + /// OPTIONAL. Array of OAuth 2.0 grant types that the client will restrict itself to using 22 + let grantTypes: [String]? 23 + 24 + /// OPTIONAL. String containing a space-separated list of scope values 25 + let scope: String? 26 + 27 + /// OPTIONAL. Indicator of the requested authentication method for the token endpoint 28 + let tokenEndpointAuthMethod: String? 29 + 30 + /// OPTIONAL. JWS algorithm that must be used for signing request objects 31 + let tokenEndpointAuthSigningAlg: String? 32 + 33 + /// OPTIONAL. JWS algorithm required for signing UserInfo Responses 34 + let userinfoSignedResponseAlg: String? 35 + 36 + /// OPTIONAL. JWE algorithm required for encrypting UserInfo Responses 37 + let userinfoEncryptedResponseAlg: String? 38 + 39 + /// OPTIONAL. URL string referencing the client's JSON Web Key (JWK) Set document 40 + let jwksUri: URL? 41 + 42 + /// OPTIONAL. Client's JSON Web Key Set document value 43 + let jwks: [String: Any]? 44 + 45 + /// OPTIONAL. Kind of application: "web" or "native" 46 + let applicationType: String? 47 + 48 + /// OPTIONAL. Subject type requested for responses to this client: "public" or "pairwise" 49 + let subjectType: String? 50 + 51 + /// OPTIONAL. JWS algorithm that must be used for signing Request Objects 52 + let requestObjectSigningAlg: String? 53 + 54 + /// OPTIONAL. JWS algorithm required for signing the ID Token issued to this client 55 + let idTokenSignedResponseAlg: String? 56 + 57 + /// OPTIONAL. JWS algorithm required for signing authorization responses 58 + let authorizationSignedResponseAlg: String? 59 + 60 + /// OPTIONAL. JWE encryption encoding for authorization responses 61 + let authorizationEncryptedResponseEnc: String? 62 + 63 + /// OPTIONAL. JWE algorithm required for encrypting authorization responses 64 + let authorizationEncryptedResponseAlg: String? 65 + 66 + /// OPTIONAL. Unique client identifier 67 + let clientId: String? 68 + 69 + /// OPTIONAL. Human-readable name of the client 70 + let clientName: String? 71 + 72 + /// OPTIONAL. URL of the home page of the client 73 + let clientUri: URL? 74 + 75 + /// OPTIONAL. URL that the client provides to the end-user to read about how the profile data will be used 76 + let policyUri: URL? 77 + 78 + /// OPTIONAL. URL that the client provides to the end-user to read about the client's terms of service 79 + let tosUri: URL? 80 + 81 + /// OPTIONAL. URL that references a logo for the client application 82 + let logoUri: URL? 83 + 84 + /// OPTIONAL. Default Maximum Authentication Age in seconds 85 + /// Specifies that the End-User MUST be actively authenticated if the End-User was authenticated 86 + /// longer ago than the specified number of seconds 87 + let defaultMaxAge: Int? 88 + 89 + /// OPTIONAL. Whether the auth_time Claim in the ID Token is REQUIRED 90 + let requireAuthTime: Bool? 91 + 92 + /// OPTIONAL. Array of email addresses of people responsible for this client 93 + let contacts: [String]? 94 + 95 + /// OPTIONAL. Whether TLS client certificate bound access tokens are requested 96 + let tlsClientCertificateBoundAccessTokens: Bool? 97 + 98 + /// OPTIONAL. Whether DPoP-bound access tokens are requested (RFC 9449 Section 5.2) 99 + let dpopBoundAccessTokens: Bool? 100 + 101 + /// OPTIONAL. Array of authorization details types supported (RFC 9396 Section 14.5) 102 + let authorizationDetailsTypes: [String]? 103 + 104 + enum CodingKeys: String, CodingKey { 105 + case redirectUris = "redirect_uris" 106 + case responseTypes = "response_types" 107 + case grantTypes = "grant_types" 108 + case scope 109 + case tokenEndpointAuthMethod = "token_endpoint_auth_method" 110 + case tokenEndpointAuthSigningAlg = "token_endpoint_auth_signing_alg" 111 + case userinfoSignedResponseAlg = "userinfo_signed_response_alg" 112 + case userinfoEncryptedResponseAlg = "userinfo_encrypted_response_alg" 113 + case jwksUri = "jwks_uri" 114 + case jwks 115 + case applicationType = "application_type" 116 + case subjectType = "subject_type" 117 + case requestObjectSigningAlg = "request_object_signing_alg" 118 + case idTokenSignedResponseAlg = "id_token_signed_response_alg" 119 + case authorizationSignedResponseAlg = "authorization_signed_response_alg" 120 + case authorizationEncryptedResponseEnc = "authorization_encrypted_response_enc" 121 + case authorizationEncryptedResponseAlg = "authorization_encrypted_response_alg" 122 + case clientId = "client_id" 123 + case clientName = "client_name" 124 + case clientUri = "client_uri" 125 + case policyUri = "policy_uri" 126 + case tosUri = "tos_uri" 127 + case logoUri = "logo_uri" 128 + case defaultMaxAge = "default_max_age" 129 + case requireAuthTime = "require_auth_time" 130 + case contacts 131 + case tlsClientCertificateBoundAccessTokens = "tls_client_certificate_bound_access_tokens" 132 + case dpopBoundAccessTokens = "dpop_bound_access_tokens" 133 + case authorizationDetailsTypes = "authorization_details_types" 134 + } 135 + 136 + // Custom decoder to handle the jwks field which can contain arbitrary JSON 137 + init(from decoder: Decoder) throws { 138 + let container = try decoder.container(keyedBy: CodingKeys.self) 139 + 140 + redirectUris = try container.decode([URL].self, forKey: .redirectUris) 141 + responseTypes = try container.decodeIfPresent([String].self, forKey: .responseTypes) 142 + grantTypes = try container.decodeIfPresent([String].self, forKey: .grantTypes) 143 + scope = try container.decodeIfPresent(String.self, forKey: .scope) 144 + tokenEndpointAuthMethod = try container.decodeIfPresent(String.self, forKey: .tokenEndpointAuthMethod) 145 + tokenEndpointAuthSigningAlg = try container.decodeIfPresent(String.self, forKey: .tokenEndpointAuthSigningAlg) 146 + userinfoSignedResponseAlg = try container.decodeIfPresent(String.self, forKey: .userinfoSignedResponseAlg) 147 + userinfoEncryptedResponseAlg = try container.decodeIfPresent(String.self, forKey: .userinfoEncryptedResponseAlg) 148 + jwksUri = try container.decodeIfPresent(URL.self, forKey: .jwksUri) 149 + 150 + // Decode jwks as generic dictionary 151 + if let jwksData = try? container.decodeIfPresent(Data.self, forKey: .jwks), 152 + let jwksDict = try? JSONSerialization.jsonObject(with: jwksData) as? [String: Any] { 153 + jwks = jwksDict 154 + } else { 155 + jwks = nil 156 + } 157 + 158 + applicationType = try container.decodeIfPresent(String.self, forKey: .applicationType) 159 + subjectType = try container.decodeIfPresent(String.self, forKey: .subjectType) 160 + requestObjectSigningAlg = try container.decodeIfPresent(String.self, forKey: .requestObjectSigningAlg) 161 + idTokenSignedResponseAlg = try container.decodeIfPresent(String.self, forKey: .idTokenSignedResponseAlg) 162 + authorizationSignedResponseAlg = try container.decodeIfPresent(String.self, forKey: .authorizationSignedResponseAlg) 163 + authorizationEncryptedResponseEnc = try container.decodeIfPresent(String.self, forKey: .authorizationEncryptedResponseEnc) 164 + authorizationEncryptedResponseAlg = try container.decodeIfPresent(String.self, forKey: .authorizationEncryptedResponseAlg) 165 + clientId = try container.decodeIfPresent(String.self, forKey: .clientId) 166 + clientName = try container.decodeIfPresent(String.self, forKey: .clientName) 167 + clientUri = try container.decodeIfPresent(URL.self, forKey: .clientUri) 168 + policyUri = try container.decodeIfPresent(URL.self, forKey: .policyUri) 169 + tosUri = try container.decodeIfPresent(URL.self, forKey: .tosUri) 170 + logoUri = try container.decodeIfPresent(URL.self, forKey: .logoUri) 171 + defaultMaxAge = try container.decodeIfPresent(Int.self, forKey: .defaultMaxAge) 172 + requireAuthTime = try container.decodeIfPresent(Bool.self, forKey: .requireAuthTime) 173 + contacts = try container.decodeIfPresent([String].self, forKey: .contacts) 174 + tlsClientCertificateBoundAccessTokens = try container.decodeIfPresent(Bool.self, forKey: .tlsClientCertificateBoundAccessTokens) 175 + dpopBoundAccessTokens = try container.decodeIfPresent(Bool.self, forKey: .dpopBoundAccessTokens) 176 + authorizationDetailsTypes = try container.decodeIfPresent([String].self, forKey: .authorizationDetailsTypes) 177 + } 178 + 179 + // Custom encoder to handle the jwks field 180 + func encode(to encoder: Encoder) throws { 181 + var container = encoder.container(keyedBy: CodingKeys.self) 182 + 183 + try container.encode(redirectUris, forKey: .redirectUris) 184 + try container.encodeIfPresent(responseTypes, forKey: .responseTypes) 185 + try container.encodeIfPresent(grantTypes, forKey: .grantTypes) 186 + try container.encodeIfPresent(scope, forKey: .scope) 187 + try container.encodeIfPresent(tokenEndpointAuthMethod, forKey: .tokenEndpointAuthMethod) 188 + try container.encodeIfPresent(tokenEndpointAuthSigningAlg, forKey: .tokenEndpointAuthSigningAlg) 189 + try container.encodeIfPresent(userinfoSignedResponseAlg, forKey: .userinfoSignedResponseAlg) 190 + try container.encodeIfPresent(userinfoEncryptedResponseAlg, forKey: .userinfoEncryptedResponseAlg) 191 + try container.encodeIfPresent(jwksUri, forKey: .jwksUri) 192 + 193 + // Encode jwks as generic dictionary 194 + if let jwks = jwks, 195 + let jwksData = try? JSONSerialization.data(withJSONObject: jwks) { 196 + try container.encode(jwksData, forKey: .jwks) 197 + } 198 + 199 + try container.encodeIfPresent(applicationType, forKey: .applicationType) 200 + try container.encodeIfPresent(subjectType, forKey: .subjectType) 201 + try container.encodeIfPresent(requestObjectSigningAlg, forKey: .requestObjectSigningAlg) 202 + try container.encodeIfPresent(idTokenSignedResponseAlg, forKey: .idTokenSignedResponseAlg) 203 + try container.encodeIfPresent(authorizationSignedResponseAlg, forKey: .authorizationSignedResponseAlg) 204 + try container.encodeIfPresent(authorizationEncryptedResponseEnc, forKey: .authorizationEncryptedResponseEnc) 205 + try container.encodeIfPresent(authorizationEncryptedResponseAlg, forKey: .authorizationEncryptedResponseAlg) 206 + try container.encodeIfPresent(clientId, forKey: .clientId) 207 + try container.encodeIfPresent(clientName, forKey: .clientName) 208 + try container.encodeIfPresent(clientUri, forKey: .clientUri) 209 + try container.encodeIfPresent(policyUri, forKey: .policyUri) 210 + try container.encodeIfPresent(tosUri, forKey: .tosUri) 211 + try container.encodeIfPresent(logoUri, forKey: .logoUri) 212 + try container.encodeIfPresent(defaultMaxAge, forKey: .defaultMaxAge) 213 + try container.encodeIfPresent(requireAuthTime, forKey: .requireAuthTime) 214 + try container.encodeIfPresent(contacts, forKey: .contacts) 215 + try container.encodeIfPresent(tlsClientCertificateBoundAccessTokens, forKey: .tlsClientCertificateBoundAccessTokens) 216 + try container.encodeIfPresent(dpopBoundAccessTokens, forKey: .dpopBoundAccessTokens) 217 + try container.encodeIfPresent(authorizationDetailsTypes, forKey: .authorizationDetailsTypes) 218 + } 219 + } 220 + 221 + /// OAuth 2.0 Authorization Server Metadata 222 + /// Based on RFC 8414 and related specifications 223 + struct OAuthAuthorizationServerMetadata: Codable { 224 + /// The authorization server's issuer identifier 225 + let issuer: String 226 + 227 + /// Array of claim types supported 228 + let claimsSupported: [String]? 229 + 230 + /// Languages and scripts supported for claims 231 + let claimsLocalesSupported: [String]? 232 + 233 + /// Whether the claims parameter is supported 234 + let claimsParameterSupported: Bool? 235 + 236 + /// Whether the request parameter is supported 237 + let requestParameterSupported: Bool? 238 + 239 + /// Whether the request_uri parameter is supported 240 + let requestUriParameterSupported: Bool? 241 + 242 + /// Whether request_uri values must be pre-registered 243 + let requireRequestUriRegistration: Bool? 244 + 245 + /// Array of OAuth 2.0 scope values supported 246 + let scopesSupported: [String]? 247 + 248 + /// Subject identifier types supported 249 + let subjectTypesSupported: [String]? 250 + 251 + /// Response types supported 252 + let responseTypesSupported: [String]? 253 + 254 + /// Response modes supported 255 + let responseModesSupported: [String]? 256 + 257 + /// Grant types supported 258 + let grantTypesSupported: [String]? 259 + 260 + /// PKCE code challenge methods supported 261 + let codeChallengeMethodsSupported: [String]? 262 + 263 + /// Languages and scripts supported for UI 264 + let uiLocalesSupported: [String]? 265 + 266 + /// Algorithms supported for signing ID tokens 267 + let idTokenSigningAlgValuesSupported: [String]? 268 + 269 + /// Display values supported 270 + let displayValuesSupported: [String]? 271 + 272 + /// Prompt values supported 273 + let promptValuesSupported: [String]? 274 + 275 + /// Algorithms supported for signing request objects 276 + let requestObjectSigningAlgValuesSupported: [String]? 277 + 278 + /// Whether authorization response issuer parameter is supported 279 + let authorizationResponseIssParameterSupported: Bool? 280 + 281 + /// Authorization details types supported 282 + let authorizationDetailsTypesSupported: [String]? 283 + 284 + /// Algorithms supported for encrypting request objects 285 + let requestObjectEncryptionAlgValuesSupported: [String]? 286 + 287 + /// Encryption encodings supported for request objects 288 + let requestObjectEncryptionEncValuesSupported: [String]? 289 + 290 + /// URL of the authorization server's JWK Set document 291 + let jwksUri: URL? 292 + 293 + /// URL of the authorization endpoint 294 + let authorizationEndpoint: URL 295 + 296 + /// URL of the token endpoint 297 + let tokenEndpoint: URL 298 + 299 + /// Authentication methods supported at token endpoint (RFC 8414 Section 2) 300 + let tokenEndpointAuthMethodsSupported: [String]? 301 + 302 + /// Signing algorithms supported for token endpoint authentication 303 + let tokenEndpointAuthSigningAlgValuesSupported: [String]? 304 + 305 + /// URL of the revocation endpoint 306 + let revocationEndpoint: URL? 307 + 308 + /// Authentication methods supported at revocation endpoint 309 + let revocationEndpointAuthMethodsSupported: [String]? 310 + 311 + /// Signing algorithms supported for revocation endpoint authentication 312 + let revocationEndpointAuthSigningAlgValuesSupported: [String]? 313 + 314 + /// URL of the introspection endpoint 315 + let introspectionEndpoint: URL? 316 + 317 + /// Authentication methods supported at introspection endpoint 318 + let introspectionEndpointAuthMethodsSupported: [String]? 319 + 320 + /// Signing algorithms supported for introspection endpoint authentication 321 + let introspectionEndpointAuthSigningAlgValuesSupported: [String]? 322 + 323 + /// URL of the pushed authorization request endpoint 324 + let pushedAuthorizationRequestEndpoint: URL? 325 + 326 + /// Authentication methods supported at PAR endpoint 327 + let pushedAuthorizationRequestEndpointAuthMethodsSupported: [String]? 328 + 329 + /// Signing algorithms supported for PAR endpoint authentication 330 + let pushedAuthorizationRequestEndpointAuthSigningAlgValuesSupported: [String]? 331 + 332 + /// Whether pushed authorization requests are required 333 + let requirePushedAuthorizationRequests: Bool? 334 + 335 + /// URL of the UserInfo endpoint 336 + let userinfoEndpoint: URL? 337 + 338 + /// URL of the end session endpoint 339 + let endSessionEndpoint: URL? 340 + 341 + /// URL of the dynamic client registration endpoint 342 + let registrationEndpoint: URL? 343 + 344 + /// DPoP signing algorithms supported (RFC 9449 Section 5.1) 345 + let dpopSigningAlgValuesSupported: [String]? 346 + 347 + /// Protected resource URIs (RFC 9728 Section 4) 348 + let protectedResources: [URL]? 349 + 350 + /// Whether client ID metadata document is supported 351 + let clientIdMetadataDocumentSupported: Bool? 352 + 353 + enum CodingKeys: String, CodingKey { 354 + case issuer 355 + case claimsSupported = "claims_supported" 356 + case claimsLocalesSupported = "claims_locales_supported" 357 + case claimsParameterSupported = "claims_parameter_supported" 358 + case requestParameterSupported = "request_parameter_supported" 359 + case requestUriParameterSupported = "request_uri_parameter_supported" 360 + case requireRequestUriRegistration = "require_request_uri_registration" 361 + case scopesSupported = "scopes_supported" 362 + case subjectTypesSupported = "subject_types_supported" 363 + case responseTypesSupported = "response_types_supported" 364 + case responseModesSupported = "response_modes_supported" 365 + case grantTypesSupported = "grant_types_supported" 366 + case codeChallengeMethodsSupported = "code_challenge_methods_supported" 367 + case uiLocalesSupported = "ui_locales_supported" 368 + case idTokenSigningAlgValuesSupported = "id_token_signing_alg_values_supported" 369 + case displayValuesSupported = "display_values_supported" 370 + case promptValuesSupported = "prompt_values_supported" 371 + case requestObjectSigningAlgValuesSupported = "request_object_signing_alg_values_supported" 372 + case authorizationResponseIssParameterSupported = "authorization_response_iss_parameter_supported" 373 + case authorizationDetailsTypesSupported = "authorization_details_types_supported" 374 + case requestObjectEncryptionAlgValuesSupported = "request_object_encryption_alg_values_supported" 375 + case requestObjectEncryptionEncValuesSupported = "request_object_encryption_enc_values_supported" 376 + case jwksUri = "jwks_uri" 377 + case authorizationEndpoint = "authorization_endpoint" 378 + case tokenEndpoint = "token_endpoint" 379 + case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported" 380 + case tokenEndpointAuthSigningAlgValuesSupported = "token_endpoint_auth_signing_alg_values_supported" 381 + case revocationEndpoint = "revocation_endpoint" 382 + case revocationEndpointAuthMethodsSupported = "revocation_endpoint_auth_methods_supported" 383 + case revocationEndpointAuthSigningAlgValuesSupported = "revocation_endpoint_auth_signing_alg_values_supported" 384 + case introspectionEndpoint = "introspection_endpoint" 385 + case introspectionEndpointAuthMethodsSupported = "introspection_endpoint_auth_methods_supported" 386 + case introspectionEndpointAuthSigningAlgValuesSupported = "introspection_endpoint_auth_signing_alg_values_supported" 387 + case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint" 388 + case pushedAuthorizationRequestEndpointAuthMethodsSupported = "pushed_authorization_request_endpoint_auth_methods_supported" 389 + case pushedAuthorizationRequestEndpointAuthSigningAlgValuesSupported = "pushed_authorization_request_endpoint_auth_signing_alg_values_supported" 390 + case requirePushedAuthorizationRequests = "require_pushed_authorization_requests" 391 + case userinfoEndpoint = "userinfo_endpoint" 392 + case endSessionEndpoint = "end_session_endpoint" 393 + case registrationEndpoint = "registration_endpoint" 394 + case dpopSigningAlgValuesSupported = "dpop_signing_alg_values_supported" 395 + case protectedResources = "protected_resources" 396 + case clientIdMetadataDocumentSupported = "client_id_metadata_document_supported" 397 + } 398 + } 399 + 400 + /// OAuth 2.0 Protected Resource Metadata 401 + /// Based on RFC 9728 Section 3.2 402 + /// - SeeAlso: [RFC 9728 Section 3.2](https://www.rfc-editor.org/rfc/rfc9728.html#section-3.2) 403 + struct OAuthProtectedResourceMetadata: Codable { 404 + /// REQUIRED. The protected resource's resource identifier, which is a URL that 405 + /// uses the https scheme and has no query or fragment components. 406 + let resource: URL 407 + 408 + /// OPTIONAL. JSON array containing a list of OAuth authorization server issuer 409 + /// identifiers, as defined in RFC8414, for authorization servers that can be 410 + /// used with this protected resource. 411 + let authorizationServers: [String]? 412 + 413 + /// OPTIONAL. URL of the protected resource's JWK Set document. 414 + let jwksUri: URL? 415 + 416 + /// RECOMMENDED. JSON array containing a list of the OAuth 2.0 scope values that 417 + /// are used in authorization requests to request access to this protected resource. 418 + let scopesSupported: [String]? 419 + 420 + /// OPTIONAL. JSON array containing a list of the supported methods of sending 421 + /// an OAuth 2.0 Bearer Token to the protected resource. 422 + let bearerMethodsSupported: [String]? 423 + 424 + /// OPTIONAL. JSON array containing a list of the JWS signing algorithms 425 + /// supported by the protected resource for signing resource responses. 426 + let resourceSigningAlgValuesSupported: [String]? 427 + 428 + /// OPTIONAL. URL of a page containing human-readable information that 429 + /// developers might want or need to know when using the protected resource. 430 + let resourceDocumentation: URL? 431 + 432 + /// OPTIONAL. URL that the protected resource provides to read about the 433 + /// protected resource's requirements on how the client can use the data. 434 + let resourcePolicyUri: URL? 435 + 436 + /// OPTIONAL. URL that the protected resource provides to read about the 437 + /// protected resource's terms of service. 438 + let resourceTosUri: URL? 439 + 440 + enum CodingKeys: String, CodingKey { 441 + case resource 442 + case authorizationServers = "authorization_servers" 443 + case jwksUri = "jwks_uri" 444 + case scopesSupported = "scopes_supported" 445 + case bearerMethodsSupported = "bearer_methods_supported" 446 + case resourceSigningAlgValuesSupported = "resource_signing_alg_values_supported" 447 + case resourceDocumentation = "resource_documentation" 448 + case resourcePolicyUri = "resource_policy_uri" 449 + case resourceTosUri = "resource_tos_uri" 450 + } 451 + } 452 + 453 + /// OAuth 2.0 Pushed Authorization Request (PAR) Response 454 + /// Based on RFC 9126 Section 2.2 455 + /// - SeeAlso: [RFC 9126 Section 2.2](https://www.rfc-editor.org/rfc/rfc9126.html#section-2.2) 456 + struct OAuthPushedAuthorizationResponse: Codable { 457 + /// REQUIRED. The request URI corresponding to the authorization request posted. 458 + /// This URI is a single-use reference to the respective request data in the 459 + /// subsequent authorization request. The way the authorization process obtains 460 + /// the authorization request data is at the discretion of the authorization server 461 + /// and is out of scope of this specification. 462 + let requestUri: String 463 + 464 + /// REQUIRED. A JSON number that represents the lifetime of the request URI in seconds. 465 + /// The request URI lifetime is at the discretion of the authorization server but 466 + /// will typically be relatively short (e.g., between 5 and 600 seconds). 467 + let expiresIn: Int 468 + 469 + enum CodingKeys: String, CodingKey { 470 + case requestUri = "request_uri" 471 + case expiresIn = "expires_in" 472 + } 473 + } 474 + 475 + 476 +
+48
Sources/Gulliver/Utils.swift
··· 1 + // 2 + // File.swift 3 + // Gulliver 4 + // 5 + // Created by Bailey Townsend on 1/20/26. 6 + // 7 + 8 + import Foundation 9 + 10 + public func base64URLEncode(_ data: Data) -> String { 11 + return data.base64EncodedString() 12 + .replacingOccurrences(of: "+", with: "-") 13 + .replacingOccurrences(of: "/", with: "_") 14 + .replacingOccurrences(of: "=", with: "") 15 + } 16 + 17 + 18 + //TODO try to do something cleaver for dpop retries 19 + extension Task where Failure == Error { 20 + @discardableResult 21 + static func retrying( 22 + priority: TaskPriority? = nil, 23 + maxRetryCount: Int = 3, 24 + retryDelay: TimeInterval = 1, 25 + operation: @Sendable @escaping () async throws -> Success, 26 + onRetry: (@Sendable (Error, Int) async throws -> Void)? = nil 27 + ) -> Task { 28 + Task(priority: priority) { 29 + for attempt in 0..<maxRetryCount { 30 + do { 31 + return try await operation() 32 + } catch { 33 + // Allow caller to handle the error and update state 34 + try await onRetry?(error, attempt) 35 + 36 + let oneSecond = TimeInterval(1_000_000_000) 37 + let delay = UInt64(oneSecond * retryDelay) 38 + try await Task<Never, Never>.sleep(nanoseconds: delay) 39 + 40 + continue 41 + } 42 + } 43 + 44 + try Task<Never, Never>.checkCancellation() 45 + return try await operation() 46 + } 47 + } 48 + }