···5566let package = Package(
77 name: "Gulliver",
88+ platforms: [
99+ .iOS(.v14),
1010+ .macOS(.v13),
1111+ .tvOS(.v14),
1212+ .visionOS(.v1),
1313+ .watchOS(.v9)
1414+ ],
815 products: [
916 // Products define the executables and libraries a package produces, making them visible to other packages.
1017 .library(
···1219 targets: ["Gulliver"]
1320 ),
1421 ],
2222+ dependencies: [
2323+ .package(url: "https://github.com/fatfingers23/ATIdentityTools.git", branch: "main"),
2424+// .package(path: "../ATIdentityTools"),
2525+ .package(url: "https://github.com/hyperoslo/Cache.git", .upToNextMajor(from: "7.4.0")),
2626+ .package(url: "https://github.com/airsidemobile/JOSESwift.git", from: "3.0.0")
2727+ ],
1528 targets: [
1629 // Targets are the basic building blocks of a package, defining a module or a test suite.
1730 // Targets can depend on other targets in this package and products from dependencies.
1831 .target(
1919- name: "Gulliver"
3232+ name: "Gulliver",
3333+ dependencies: [
3434+ .product(name: "ATIdentityTools", package: "atidentitytools"),
3535+ .product(name: "Cache", package: "Cache"),
3636+// "jose-swift"
3737+ "JOSESwift"
3838+ ]
2039 ),
2121-4040+2241 ]
2342)
+150
Sources/Gulliver/ATProtoClient.swift
···11+//
22+// ATProtoClient.swift
33+// Gulliver
44+//
55+// Created by Bailey Townsend on 1/21/26.
66+//
77+88+import Foundation
99+1010+actor ATProtoClient {
1111+ private var accessToken: String?
1212+ private var refreshToken: String?
1313+ private var dpopNonce: String?
1414+ private let session: URLSession
1515+ private let sessionId: String?
1616+ private let keychainStore: KeychainStorage
1717+ private let dpopSigner: DPoPSigner
1818+1919+ enum ATProtoError: Error {
2020+ case unauthorized
2121+ case dpopNonceRequired(nonce: String?)
2222+ case maxRetriesExceeded
2323+ case networkError(Error)
2424+ }
2525+2626+ init(sessionId: String, session: URLSession = .shared) throws {
2727+ self.sessionId = sessionId
2828+ self.session = session
2929+ //We will always use the session keychain here since the state is used else where
3030+ self.keychainStore = getSessionKeychainStore()
3131+ let dpopKey = try self.keychainStore.retrieveDPoPKey(keyTag: sessionId)
3232+ self.dpopSigner = DPoPSigner(privateKey: dpopKey, keychainStore: self.keychainStore)
3333+ }
3434+3535+3636+ func request<T: Decodable & Sendable>(
3737+ _ endpoint: String,
3838+ method: String = "GET",
3939+ body: Data? = nil,
4040+ maxRetries: Int = 3
4141+ ) async throws -> T {
4242+ try await Task.retrying(
4343+ maxRetryCount: maxRetries,
4444+ retryDelay: 0.5, // Shorter delay for API retries
4545+ operation: {
4646+ try await self.performRequest(endpoint, method: method, body: body)
4747+ },
4848+ onRetry: { [weak self] error, attempt in
4949+ guard let self else { return }
5050+5151+ switch error {
5252+ case let atError as ATProtoError:
5353+ switch atError {
5454+ case .dpopNonceRequired(let nonce):
5555+ // Nonce already updated, just retry
5656+ print("Retrying with DPoP nonce (attempt \(attempt + 1))")
5757+5858+ case .unauthorized:
5959+ // Try to refresh token
6060+ print("Refreshing access token (attempt \(attempt + 1))")
6161+ try await self.refreshAccessToken()
6262+6363+ default:
6464+ throw error // Don't retry other errors
6565+ }
6666+6767+ default:
6868+ throw error // Don't retry unknown errors
6969+ }
7070+ }
7171+ ).value
7272+ }
7373+7474+ private func performRequest<T: Decodable>(
7575+ _ endpoint: String,
7676+ method: String,
7777+ body: Data?
7878+ ) async throws -> T {
7979+ guard let url = URL(string: endpoint) else {
8080+ throw URLError(.badURL)
8181+ }
8282+8383+ var request = URLRequest(url: url)
8484+ request.httpMethod = method
8585+ request.httpBody = body
8686+8787+ if let accessToken {
8888+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
8989+ }
9090+9191+ let dpopProof = try self.dpopSigner.createProof(httpMethod: method, url: endpoint.lowercased())
9292+9393+9494+ request.setValue(dpopProof, forHTTPHeaderField: "DPoP")
9595+9696+ let (data, response) = try await session.data(for: request)
9797+9898+ guard let httpResponse = response as? HTTPURLResponse else {
9999+ throw URLError(.badServerResponse)
100100+ }
101101+102102+ // Always capture the nonce if present
103103+ if let newNonce = httpResponse.value(forHTTPHeaderField: "DPoP-Nonce") {
104104+ //save to cache now
105105+ dpopNonce = newNonce
106106+ }
107107+108108+ //TODO maybe abstract this out to be used?
109109+ // Handle error cases
110110+ if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
111111+ if let dpopNonce = httpResponse.value(forHTTPHeaderField: "dpop-nonce")
112112+ {
113113+ throw ATProtoError.dpopNonceRequired(nonce: dpopNonce)
114114+ }
115115+ throw ATProtoError.unauthorized
116116+ }
117117+118118+ guard (200...299).contains(httpResponse.statusCode) else {
119119+ throw URLError(.badServerResponse)
120120+ }
121121+122122+ return try JSONDecoder().decode(T.self, from: data)
123123+ }
124124+125125+ private func refreshAccessToken() async throws {
126126+ guard let refreshToken else {
127127+ throw ATProtoError.unauthorized
128128+ }
129129+130130+ struct RefreshResponse: Decodable {
131131+ let accessJwt: String
132132+ let refreshJwt: String
133133+ }
134134+135135+ let body = try JSONEncoder().encode(["refreshToken": refreshToken])
136136+137137+ // Refresh can also require DPoP retries, so it goes through request()
138138+ let response: RefreshResponse = try await request(
139139+ "https://your-pds.host/xrpc/com.atproto.server.refreshSession",
140140+ method: "POST",
141141+ body: body,
142142+ maxRetries: 2 // Fewer retries for refresh
143143+ )
144144+145145+ accessToken = response.accessJwt
146146+ self.refreshToken = response.refreshJwt
147147+ }
148148+149149+150150+}
+176
Sources/Gulliver/DPoP.swift
···11+//
22+// dpop.swift
33+// Gulliver
44+//
55+// Created by Bailey Townsend on 1/20/26.
66+//
77+88+import Foundation
99+import CryptoKit
1010+import JOSESwift
1111+1212+1313+/// DPoP Signer for creating dpop+jwt proofs with P-256 keys
1414+public struct DPoPSigner {
1515+ private let privateKey: P256.Signing.PrivateKey
1616+ /// This can either be the state or session keychain depending if it's doing par or not
1717+ private let keychainStore: KeychainStorage
1818+1919+2020+ public init(privateKey: P256.Signing.PrivateKey, keychainStore: KeychainStorage) {
2121+ self.privateKey = privateKey
2222+ self.keychainStore = keychainStore
2323+ }
2424+2525+ //TODO
2626+ // Save state/session metadata is saved to store via store methods, but dpop keys are stored by key thing
2727+ // the dpop keys are saved by sessionid or
2828+ // Also need to check on those save and delete cause they dont follow the namespace?
2929+3030+3131+ /// Session id is either the state key or the session key
3232+ public func saveCurrentDpopKey(sessionId: String) throws {
3333+ try keychainStore.storeDPoPKey(self.privateKey, keyTag: sessionId )
3434+ }
3535+3636+ /// Session id is either the state key or the session key
3737+3838+ public func deleteCurrentDpopKey(sessionId: String) throws {
3939+ try keychainStore.deleteDPoPKey(keyTag: sessionId)
4040+ }
4141+4242+ /// Creates a DPoP proof JWT
4343+ /// - Parameters:
4444+ /// - httpMethod: The HTTP method of the request (e.g., "POST", "GET")
4545+ /// - url: The URL of the request (query and fragment will be stripped)
4646+ /// - accessToken: Optional access token for resource access (will be hashed as 'ath' claim)
4747+ /// - nonce: Optional server-provided nonce
4848+ /// - Returns: A DPoP proof JWT string
4949+ public func createProof(
5050+ httpMethod: String,
5151+ url: String,
5252+ accessToken: String? = nil,
5353+ nonce: String? = nil
5454+ ) throws -> String {
5555+ // Build the JWK for the public key
5656+ let jwkDict = try createJWKDictionary()
5757+5858+ // Build header
5959+ let headerDict: [String: Any] = [
6060+ "typ": "dpop+jwt",
6161+ "alg": "ES256",
6262+ "jwk": jwkDict
6363+ ]
6464+6565+ // Build claims
6666+ let jti = UUID().uuidString
6767+ let iat = Int(Date().timeIntervalSince1970)
6868+ let htu = sanitizeURL(url)
6969+7070+ var claimsDict: [String: Any] = [
7171+ "jti": jti,
7272+ "htm": httpMethod.uppercased(),
7373+ "htu": htu,
7474+ "iat": iat
7575+ ]
7676+7777+ // Add nonce if provided
7878+ if let nonce = nonce {
7979+ claimsDict["nonce"] = nonce
8080+ }
8181+8282+ // Add access token hash if provided (for resource access)
8383+ if let accessToken = accessToken {
8484+ let ath = computeAccessTokenHash(accessToken)
8585+ claimsDict["ath"] = ath
8686+ }
8787+8888+ // Create the JWT
8989+ let headerData = try JSONSerialization.data(withJSONObject: headerDict)
9090+ let claimsData = try JSONSerialization.data(withJSONObject: claimsDict)
9191+9292+ let headerBase64 = base64URLEncode(headerData)
9393+ let claimsBase64 = base64URLEncode(claimsData)
9494+9595+ let signingInput = "\(headerBase64).\(claimsBase64)"
9696+9797+ guard let signingData = signingInput.data(using: .utf8) else {
9898+ throw DPoPError.encodingFailed
9999+ }
100100+101101+ // Sign with ES256 (P-256 + SHA-256)
102102+ let signature = try privateKey.signature(for: signingData)
103103+ let signatureBase64 = base64URLEncode(signature.rawRepresentation)
104104+105105+ return "\(signingInput).\(signatureBase64)"
106106+ }
107107+108108+ /// Creates a JWK dictionary representation of the public key
109109+ private func createJWKDictionary() throws -> [String: String] {
110110+ let publicKey = privateKey.publicKey
111111+ let rawRepresentation = publicKey.rawRepresentation
112112+113113+ // P-256 raw representation is 64 bytes: 32 bytes for x, 32 bytes for y
114114+ guard rawRepresentation.count == 64 else {
115115+ throw DPoPError.invalidKeyFormat
116116+ }
117117+118118+ let x = rawRepresentation.prefix(32)
119119+ let y = rawRepresentation.suffix(32)
120120+121121+ return [
122122+ "kty": "EC",
123123+ "crv": "P-256",
124124+ "x": base64URLEncode(Data(x)),
125125+ "y": base64URLEncode(Data(y))
126126+ ]
127127+ }
128128+129129+ /// Sanitizes URL by removing query and fragment
130130+ private func sanitizeURL(_ urlString: String) -> String {
131131+ guard let url = URL(string: urlString),
132132+ var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
133133+ return urlString
134134+ }
135135+ components.query = nil
136136+ components.fragment = nil
137137+ return components.string ?? urlString
138138+ }
139139+140140+ /// Computes the access token hash (ath) using SHA-256
141141+ private func computeAccessTokenHash(_ accessToken: String) -> String {
142142+ guard let tokenData = accessToken.data(using: .utf8) else {
143143+ return ""
144144+ }
145145+ let hash = SHA256.hash(data: tokenData)
146146+ return base64URLEncode(Data(hash))
147147+ }
148148+149149+150150+ /// Returns the public key JWK as a JSON string (useful for client metadata)
151151+ public func publicKeyJWK() throws -> String {
152152+ let jwkDict = try createJWKDictionary()
153153+ let data = try JSONSerialization.data(withJSONObject: jwkDict, options: .sortedKeys)
154154+ return String(data: data, encoding: .utf8) ?? ""
155155+ }
156156+}
157157+158158+/// Errors that can occur during DPoP operations
159159+public enum DPoPError: Error {
160160+ case encodingFailed
161161+ case invalidKeyFormat
162162+ case signingFailed
163163+}
164164+165165+// MARK: - Keychain Storage Helpers
166166+167167+func createDPoPKey(for sessionId: String) async throws -> P256.Signing.PrivateKey {
168168+ let newKey = P256.Signing.PrivateKey()
169169+ //Will always want the state store when creating a new key chain. transfer it over during login
170170+ let storage = getStateKeychainStore()
171171+ try await storage.storeDPoPKey(newKey, keyTag: sessionId)
172172+ return newKey
173173+}
174174+175175+176176+
+44
Sources/Gulliver/Errors.swift
···11+//
22+// File.swift
33+// Gulliver
44+//
55+// Created by Bailey Townsend on 1/20/26.
66+//
77+88+import Foundation
99+1010+1111+extension OAuthClientError: LocalizedError {
1212+ var errorDescription: String? {
1313+ switch self {
1414+ case .identifierParsingFailed:
1515+ return "Failed to parse the identifier as a did, handle, or PDS URL."
1616+ case .couldNotResolveADID:
1717+ return "Could not resolve a DID for the provided input. Is this a valid handle or DID?"
1818+ case .noPdsFound:
1919+ return "No PDS endpoint was found for the provided identifier."
2020+ case .unknownError(let error):
2121+ let error = error as NSError
2222+ let message = error.localizedDescription.isEmpty ? "An unknown error occurred." : error.localizedDescription
2323+ return message
2424+ case .webRequestError(let message):
2525+ return message
2626+ case .metaDatasError(let message):
2727+ return message ?? "An unknown error occurred trying to get or parse the metadata from the resource server."
2828+ case .catchAll(let message):
2929+ return message
3030+ }
3131+ }
3232+}
3333+3434+3535+enum OAuthClientError: Error {
3636+ case identifierParsingFailed
3737+ case couldNotResolveADID
3838+ case noPdsFound
3939+ case unknownError(Error)
4040+ case webRequestError(String)
4141+ case metaDatasError(String?)
4242+ case catchAll(String)
4343+4444+}
+323
Sources/Gulliver/KeychainStorage.swift
···11+//
22+// File.swift
33+// Gulliver
44+//
55+// Created by Bailey Townsend on 1/20/26.
66+//
77+88+import Foundation
99+import CryptoKit
1010+1111+enum SecureStore: String {
1212+ case state = "state"
1313+ case dpop = "dpop"
1414+}
1515+1616+1717+1818+public func getStateKeychainStore() -> KeychainStorage {
1919+ return KeychainStorage(namespace: "state")
2020+}
2121+2222+public func getSessionKeychainStore() -> KeychainStorage {
2323+ return KeychainStorage(namespace: "session")
2424+}
2525+2626+2727+2828+public class KeychainStorage {
2929+ let namespace: String
3030+ private let accessGroup: String?
3131+3232+ private static var defaultAccessibility: CFString {
3333+#if os(iOS)
3434+ return kSecAttrAccessibleAfterFirstUnlock
3535+#elseif os(macOS)
3636+ return kSecAttrAccessibleAfterFirstUnlock
3737+#endif
3838+ }
3939+4040+ private static func platformSpecificAttributes() -> [String: Any] {
4141+ var attributes: [String: Any] = [:]
4242+4343+#if os(macOS)
4444+ // Disable iCloud sync for app-specific keychain items on macOS
4545+ attributes[kSecAttrSynchronizable as String] = false
4646+#endif
4747+4848+ return attributes
4949+ }
5050+5151+5252+ public init(namespace: String = "default", accessGroup: String? = nil) {
5353+ self.namespace = namespace
5454+ self.accessGroup = accessGroup
5555+ }
5656+5757+ private func getKey(did: String, store: SecureStore ) -> String {
5858+ "\(did):\(store)"
5959+ }
6060+6161+6262+6363+ func store(key: String, value: Data, namespace: String) throws {
6464+ let namespacedKey = "\(namespace):\(key)"
6565+6666+ var query: [String: Any] = [
6767+ kSecClass as String: kSecClassGenericPassword,
6868+ kSecAttrAccount as String: namespacedKey,
6969+ kSecValueData as String: value,
7070+ kSecAttrAccessible as String: Self.defaultAccessibility,
7171+ ]
7272+7373+ // Add platform-specific attributes
7474+ query.merge(Self.platformSpecificAttributes()) { _, new in new }
7575+7676+ // Delete any existing item with the same key
7777+ let deleteStatus = SecItemDelete(query as CFDictionary)
7878+ if deleteStatus != errSecSuccess, deleteStatus != errSecItemNotFound {
7979+ throw KeychainStorageError.deleteError(Int(deleteStatus))
8080+ }
8181+8282+ // Add the new item to the keychain
8383+ let status = SecItemAdd(query as CFDictionary, nil)
8484+ if status == errSecDuplicateItem {
8585+ throw KeychainStorageError.keyStoreError(Int(status))
8686+ }
8787+ guard status == errSecSuccess else {
8888+ throw KeychainStorageError.keyStoreError(Int(status))
8989+ }
9090+ }
9191+9292+ func retrieve(key: String, namespace: String) throws -> Data {
9393+ let namespacedKey = "\(namespace):\(key)"
9494+9595+ let query: [String: Any] = [
9696+ kSecClass as String: kSecClassGenericPassword,
9797+ kSecAttrAccount as String: namespacedKey,
9898+ kSecReturnData as String: kCFBooleanTrue!,
9999+ kSecMatchLimit as String: kSecMatchLimitOne,
100100+ ]
101101+102102+ var item: CFTypeRef?
103103+ let status = SecItemCopyMatching(query as CFDictionary, &item)
104104+105105+ if status == errSecItemNotFound {
106106+ throw KeychainStorageError.retrieveError(Int(status))
107107+ }
108108+109109+ guard status == errSecSuccess else {
110110+ throw KeychainStorageError.retrieveError(Int(status))
111111+ }
112112+ guard let data = item as? Data else {
113113+ throw KeychainStorageError.dataFormatError
114114+ }
115115+116116+ return data
117117+ }
118118+119119+ func delete(key: String, namespace: String) throws {
120120+ let namespacedKey = "\(namespace):\(key)"
121121+122122+123123+ let query: [String: Any] = [
124124+ kSecClass as String: kSecClassGenericPassword,
125125+ kSecAttrAccount as String: namespacedKey,
126126+ ]
127127+128128+ let status = SecItemDelete(query as CFDictionary)
129129+130130+ if status != errSecSuccess, status != errSecItemNotFound {
131131+ throw KeychainStorageError.deleteError(Int(status))
132132+ }
133133+ }
134134+135135+ func deleteAll(namespace: String) throws {
136136+ // Handle generic passwords first
137137+ let genericSuccess = try deleteGenericPasswords(withNamespacePrefix: namespace)
138138+139139+ // Then handle crypto keys
140140+ let keysSuccess = try deleteCryptoKeys(withNamespacePrefix: namespace)
141141+142142+ guard genericSuccess, keysSuccess else {
143143+ throw KeychainStorageError.deleteError(-1)
144144+ }
145145+ }
146146+147147+ private func deleteGenericPasswords(withNamespacePrefix namespace: String) throws -> Bool {
148148+ // Query to get all generic passwords
149149+ let query: [String: Any] = [
150150+ kSecClass as String: kSecClassGenericPassword,
151151+ kSecMatchLimit as String: kSecMatchLimitAll,
152152+ kSecReturnAttributes as String: true,
153153+ ]
154154+155155+ var result: AnyObject?
156156+ let status = SecItemCopyMatching(query as CFDictionary, &result)
157157+158158+ if status == errSecSuccess, let items = result as? [[String: Any]] {
159159+ var allSucceeded = true
160160+ var matchedCount = 0
161161+162162+ // Filter and delete items that match our namespace
163163+ for item in items {
164164+ if let account = item[kSecAttrAccount as String] as? String,
165165+ account.hasPrefix("\(namespace):")
166166+ {
167167+ matchedCount += 1
168168+169169+ let deleteQuery: [String: Any] = [
170170+ kSecClass as String: kSecClassGenericPassword,
171171+ kSecAttrAccount as String: account,
172172+ ]
173173+174174+ let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
175175+ if deleteStatus != errSecSuccess {
176176+ allSucceeded = false
177177+ }
178178+ }
179179+ }
180180+181181+ return allSucceeded
182182+ } else if status == errSecItemNotFound {
183183+184184+ return true
185185+ } else {
186186+ //TODO throw or just sliently fail?
187187+ return false
188188+ }
189189+ }
190190+191191+ private func deleteCryptoKeys(withNamespacePrefix namespace: String) throws -> Bool {
192192+ // Query to get all keys
193193+ let query: [String: Any] = [
194194+ kSecClass as String: kSecClassKey,
195195+ kSecMatchLimit as String: kSecMatchLimitAll,
196196+ kSecReturnAttributes as String: true,
197197+ ]
198198+199199+ var result: AnyObject?
200200+ let status = SecItemCopyMatching(query as CFDictionary, &result)
201201+202202+ if status == errSecSuccess, let items = result as? [[String: Any]] {
203203+ var allSucceeded = true
204204+ var matchedCount = 0
205205+206206+ // Filter and delete keys
207207+ for item in items {
208208+ // For keys, check the application tag
209209+ if let tagData = item[kSecAttrApplicationTag as String] as? Data,
210210+ let tagString = String(data: tagData, encoding: .utf8),
211211+ tagString.hasPrefix("\(namespace).")
212212+ {
213213+ matchedCount += 1
214214+215215+ let deleteQuery: [String: Any] = [
216216+ kSecClass as String: kSecClassKey,
217217+ kSecAttrApplicationTag as String: tagData,
218218+ ]
219219+220220+ let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
221221+ if deleteStatus != errSecSuccess {
222222+ allSucceeded = false
223223+ }
224224+ }
225225+ }
226226+227227+ return allSucceeded
228228+ } else if status == errSecItemNotFound {
229229+ return true
230230+ } else {
231231+ return false
232232+ }
233233+ }
234234+235235+ func storeDPoPKey(_ key: P256.Signing.PrivateKey, keyTag: String) throws {
236236+ guard let tagData = keyTag.data(using: .utf8) else {
237237+ throw KeychainStorageError.dataFormatError
238238+ }
239239+240240+ var query: [String: Any] = [
241241+ kSecClass as String: kSecClassKey,
242242+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
243243+ kSecAttrKeySizeInBits as String: 256,
244244+ kSecAttrApplicationTag as String: tagData,
245245+ kSecValueData as String: key.x963Representation,
246246+ kSecAttrAccessible as String: Self.defaultAccessibility,
247247+ ]
248248+249249+ // Delete any existing key first
250250+ let deleteQuery: [String: Any] = [
251251+ kSecClass as String: kSecClassKey,
252252+ kSecAttrApplicationTag as String: tagData,
253253+ ]
254254+255255+ let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
256256+257257+ // Add the new key
258258+ let status = SecItemAdd(query as CFDictionary, nil)
259259+ if status == errSecDuplicateItem {
260260+ // Try update
261261+ let updateAttributes: [String: Any] = [
262262+ kSecValueData as String: key.x963Representation,
263263+ ]
264264+ let updateStatus = SecItemUpdate(deleteQuery as CFDictionary, updateAttributes as CFDictionary)
265265+ guard updateStatus == errSecSuccess else {
266266+ throw KeychainStorageError.keyStoreError(Int(updateStatus))
267267+ }
268268+ } else if status != errSecSuccess {
269269+ throw KeychainStorageError.keyStoreError(Int(status))
270270+ }
271271+ }
272272+273273+ func retrieveDPoPKey(keyTag: String) throws -> P256.Signing.PrivateKey {
274274+ guard let tagData = keyTag.data(using: .utf8) else {
275275+ throw KeychainStorageError.dataFormatError
276276+ }
277277+278278+ let query: [String: Any] = [
279279+ kSecClass as String: kSecClassKey,
280280+ kSecAttrApplicationTag as String: tagData,
281281+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
282282+ kSecReturnData as String: kCFBooleanTrue!,
283283+ kSecMatchLimit as String: kSecMatchLimitOne,
284284+ ]
285285+286286+ var item: CFTypeRef?
287287+ let status = SecItemCopyMatching(query as CFDictionary, &item)
288288+289289+ guard status == errSecSuccess, let data = item as? Data else {
290290+ throw KeychainStorageError.retrieveError(Int(status))
291291+ }
292292+293293+ return try P256.Signing.PrivateKey(x963Representation: data)
294294+295295+ }
296296+297297+ func deleteDPoPKey(keyTag: String) throws {
298298+ guard let tagData = keyTag.data(using: .utf8) else {
299299+ throw KeychainStorageError.dataFormatError
300300+ }
301301+302302+ let query: [String: Any] = [
303303+ kSecClass as String: kSecClassKey,
304304+ kSecAttrApplicationTag as String: tagData,
305305+ ]
306306+307307+ let status = SecItemDelete(query as CFDictionary)
308308+ if status != errSecSuccess, status != errSecItemNotFound {
309309+ throw KeychainStorageError.deleteError(Int(status))
310310+ }
311311+ }
312312+313313+}
314314+315315+316316+317317+public enum KeychainStorageError: Error {
318318+ case deleteError(Int)
319319+ case keyStoreError(Int)
320320+ case retrieveError(Int)
321321+ case dataFormatError
322322+323323+}
+315
Sources/Gulliver/OAuthClient.swift
···11+//
22+// OAuthClient.swift
33+// Gulliver
44+//
55+// Created by Bailey Townsend on 1/20/26.
66+//
77+88+import Foundation
99+import ATIdentityTools
1010+import ATCommonWeb
1111+import CryptoKit
1212+import Cache
1313+1414+1515+public class OAuthClient {
1616+ private let clientId: String
1717+ private let redirectUri: String
1818+ //Who knows, you might need to override this
1919+ public var clientUrlSession: URLSession
2020+ private var didResolver: DIDResolver
2121+ private var handleResolver: HandleResolver
2222+2323+2424+ public init(
2525+ clientId: String,
2626+ redirectUri: String,
2727+ clientUrlSession: URLSession = .shared,
2828+ didOptions: DIDResolverOptions? = nil,
2929+ didUrlSession: URLSession = .shared,
3030+ handleResolver: HandleResolver? = nil) {
3131+3232+ self.didResolver = DIDResolver(options: didOptions, urlSession: didUrlSession)
3333+ if let handleResolver = handleResolver {
3434+ self.handleResolver = handleResolver
3535+ }else{
3636+ self.handleResolver = HandleResolver()
3737+ }
3838+3939+ self.clientId = clientId
4040+ self.redirectUri = redirectUri
4141+ self.clientUrlSession = clientUrlSession
4242+ }
4343+4444+4545+ /// Generic method to fetch and decode JSON from a URL
4646+ /// - Parameters:
4747+ /// - url: The URL to fetch from
4848+ /// - type: The Decodable type to decode the response into
4949+ /// - decoder: Optional custom JSONDecoder (defaults to standard JSONDecoder)
5050+ /// - Returns: The decoded object of type T
5151+ /// - Throws: OAuthClientError if the request fails or response is invalid
5252+ private func fetchJSON<T: Decodable>(from url: URL, as type: T.Type, decoder: JSONDecoder = JSONDecoder()) async throws -> T {
5353+ // Create request with proper headers
5454+ var request = URLRequest(url: url)
5555+ request.setValue("application/json", forHTTPHeaderField: "Accept")
5656+5757+ // Perform the request
5858+ let (data, response) = try await clientUrlSession.data(for: request)
5959+6060+ // Validate response
6161+ guard let httpResponse = response as? HTTPURLResponse else {
6262+ throw OAuthClientError.webRequestError("Invalid response type")
6363+ }
6464+6565+ guard httpResponse.statusCode == 200 else {
6666+ throw OAuthClientError.webRequestError("Unexpected response status: \(httpResponse.statusCode)")
6767+ }
6868+6969+ // Validate content type
7070+ guard let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type"),
7171+ contentType.lowercased().contains("application/json") else {
7272+ throw OAuthClientError.webRequestError("Unexpected content type")
7373+ }
7474+7575+ // Decode the JSON
7676+ return try decoder.decode(T.self, from: data)
7777+ }
7878+7979+8080+ /// Gets the clientmetadata from the cliend ID
8181+ private func getClientMetadata(clientId: String) async throws -> OAuthClientMetadata {
8282+ // Construct the well-known URL
8383+ guard let url = URL(string: clientId)else{
8484+ throw OAuthClientError.metaDatasError("Invalid client ID. Is not a URL")
8585+ }
8686+8787+ // Fetch and decode the metadata using the generic method
8888+ return try await fetchJSON(from: url, as: OAuthClientMetadata.self)
8989+9090+ }
9191+9292+ /// Gets the protected metadata at /.well-known/oauth-protected-resource
9393+ private func getProtectedResourceMetadata(url: URL) async throws -> OAuthProtectedResourceMetadata {
9494+ // Construct the well-known URL
9595+ guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
9696+ throw OAuthClientError.metaDatasError("Invalid URL for metadata server")
9797+ }
9898+ components.path = "/.well-known/oauth-protected-resource"
9999+100100+ guard let metadataURL = components.url else {
101101+ throw OAuthClientError.metaDatasError("Failed to construct protected metadata URL")
102102+ }
103103+104104+ // Fetch and decode the metadata using the generic method
105105+ let metadata = try await fetchJSON(from: metadataURL, as: OAuthProtectedResourceMetadata.self)
106106+107107+ // Validate that the resource matches the origin
108108+ guard let origin = components.scheme.map({ "\($0)://\(components.host ?? "")" }),
109109+ metadata.resource.absoluteString.hasPrefix(origin) else {
110110+ throw OAuthClientError.metaDatasError("Unexpected resource identifier in metadata")
111111+ }
112112+113113+ return metadata
114114+ }
115115+116116+ /// Gets the protected metadata at /.well-known/oauth-authorization-server
117117+ private func getOAuthAuthorizationServerMetadata(url: URL) async throws -> OAuthAuthorizationServerMetadata {
118118+ // Construct the well-known URL
119119+ guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
120120+ throw OAuthClientError.metaDatasError("Invalid URL for metadata server")
121121+ }
122122+ components.path = "/.well-known/oauth-authorization-server"
123123+124124+ guard let metadataURL = components.url else {
125125+ throw OAuthClientError.metaDatasError("Failed to construct metadata URL")
126126+ }
127127+128128+ // Fetch and decode the metadata using the generic method
129129+ return try await fetchJSON(from: metadataURL, as: OAuthAuthorizationServerMetadata.self)
130130+131131+ }
132132+133133+134134+ /// Generic call that calls the abstracted endpoints to get the metadatas
135135+ private func getAuthMetadata(url: URL) async throws -> OAuthAuthorizationServerMetadata {
136136+137137+ let protectedResourceMetadata = try await getProtectedResourceMetadata(url: url)
138138+139139+ guard let authorizedServers = protectedResourceMetadata.authorizationServers else {
140140+ throw OAuthClientError.metaDatasError("No authorization servers found in protected metadata")
141141+ }
142142+143143+ //Some manual checks atcute did
144144+ if authorizedServers.count != 1 {
145145+ throw OAuthClientError.metaDatasError("expected exactly one authorization server in the listing")
146146+ }
147147+148148+ let issuer = authorizedServers.first
149149+ guard let issuerUrl = URL(string: issuer ?? "") else {
150150+ throw OAuthClientError.metaDatasError("Failed to parse issuer URL from protected metadata")
151151+ }
152152+ let metadata = try await self.getAuthMetadata(url: issuerUrl)
153153+154154+ if let protectedResources = metadata.protectedResources {
155155+ if !protectedResources.contains(protectedResourceMetadata.resource) {
156156+ throw OAuthClientError.metaDatasError("server is not in authorization server's jurisdiction")
157157+ }
158158+ }
159159+160160+ return metadata
161161+ }
162162+163163+164164+ /// Generates a random code verifier for PKCE.
165165+ /// - Returns: A base64url-encoded random string.
166166+ private func generateCodeVerifier() -> String {
167167+ let verifierData = Data((0 ..< 32).map { _ in UInt8.random(in: 0 ... 255) })
168168+ return base64URLEncode(verifierData)
169169+ }
170170+171171+ /// Generates a code challenge from a code verifier.
172172+ /// - Parameter verifier: The code verifier.
173173+ /// - Returns: The base64url-encoded SHA-256 hash of the verifier.
174174+ private func generateCodeChallenge(from verifier: String) -> String {
175175+ let verifierData = Data(verifier.utf8)
176176+ let hash = SHA256.hash(data: verifierData)
177177+ return base64URLEncode(Data(hash))
178178+ }
179179+180180+ func postPAR(to url: URL, parameters: [String: String], decoder: JSONDecoder = JSONDecoder()) async throws -> OAuthPushedAuthorizationResponse {
181181+182182+ var request = URLRequest(url: url)
183183+ request.httpMethod = "POST"
184184+ request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
185185+186186+ // Convert parameters to form-encoded string
187187+ let formString = parameters
188188+ .map { key, value in
189189+ let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key
190190+ let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
191191+ return "\(encodedKey)=\(encodedValue)"
192192+ }
193193+ .joined(separator: "&")
194194+195195+ request.httpBody = formString.data(using: .utf8)
196196+// request.setValue("\(dpop)", forHTTPHeaderField: "DPoP")
197197+198198+199199+ let (data, response) = try await URLSession.shared.data(for: request)
200200+201201+ guard let httpResponse = response as? HTTPURLResponse,
202202+ (200...299).contains(httpResponse.statusCode) else {
203203+ throw URLError(.badServerResponse)
204204+ }
205205+206206+ // Decode the JSON
207207+ return try decoder.decode(OAuthPushedAuthorizationResponse.self, from: data)
208208+ }
209209+210210+211211+ public func createAuthorizationURL(identifier: String, resourceUrl: URL? = nil) async throws -> URL {
212212+ do {
213213+ var pdsUrl: URL? = nil
214214+ var did: String? = nil
215215+ var didDoc: DIDDocument? = nil
216216+217217+218218+ if identifier.starts(with: "http") {
219219+ guard let url = URL(string: identifier) else {
220220+ throw OAuthClientError.identifierParsingFailed
221221+ }
222222+ pdsUrl = url
223223+ }
224224+225225+ if pdsUrl == nil {
226226+ if identifier.starts(with: "did:") {
227227+ did = identifier
228228+ }
229229+ }
230230+231231+ if did == nil && pdsUrl == nil {
232232+ did = try await handleResolver.resolve(handle: identifier)
233233+ let didDocument = try await didResolver.resolve(did: did!, willForceRefresh: false)
234234+ didDoc = didDocument
235235+ pdsUrl = didDocument.getPDSEndpoint()
236236+ }
237237+238238+ if did == nil && pdsUrl == nil{
239239+ throw OAuthClientError.identifierParsingFailed
240240+ }
241241+242242+243243+244244+ if pdsUrl == nil {
245245+ throw OAuthClientError.catchAll("No PDS URL Found")
246246+ }
247247+248248+249249+ let oauthServerMetadata = try await getOAuthAuthorizationServerMetadata(url: resourceUrl ?? pdsUrl!)
250250+251251+ let pckeCode = generateCodeVerifier()
252252+ let pcke = generateCodeChallenge(from: pckeCode)
253253+254254+ let clientMetadata = try await getClientMetadata(clientId: self.clientId)
255255+256256+257257+ let sessionId = UUID().uuidString
258258+259259+ let parameters: [String: String] = [
260260+ "client_id": clientId,
261261+ "response_type": "code",
262262+ "state": sessionId,
263263+ "redirect_uri": self.redirectUri,
264264+ "scope": clientMetadata.scope ?? "",
265265+ "code_challenge": pcke,
266266+ "code_challenge_method": "S256",
267267+ "response_mode": "fragment",
268268+ "display": "page",
269269+ ]
270270+271271+272272+ guard let parEndpoint = oauthServerMetadata.pushedAuthorizationRequestEndpoint else {
273273+ throw OAuthClientError.catchAll("No Pushed Authorization Request Endpoint Found")
274274+ }
275275+276276+ let dpopKey = try await createDPoPKey(for: identifier)
277277+ let stateKeyStore = getStateKeychainStore()
278278+ let signer = DPoPSigner(privateKey: dpopKey, keychainStore: stateKeyStore)
279279+ let requestSign = try signer.createProof(httpMethod: "POST", url: parEndpoint.absoluteString)
280280+281281+ //PAR returns a dpop nonce i need to follow, and can also fail to a dpop needing the nonce
282282+ //resend with one sign then
283283+ let parResult = try await postPAR(to: parEndpoint, parameters: parameters)
284284+285285+ guard var components = URLComponents(url: oauthServerMetadata.authorizationEndpoint, resolvingAgainstBaseURL: true) else {
286286+ throw OAuthClientError.catchAll("Failed to construct authorization URL")
287287+ }
288288+289289+ let queryItems: [URLQueryItem] = [
290290+ URLQueryItem(name: "client_id", value: clientId),
291291+ URLQueryItem(name: "request_uri", value: parResult.requestUri),
292292+ URLQueryItem(name: "redirect_uri", value: self.redirectUri),
293293+294294+ ]
295295+ components.queryItems = (components.queryItems ?? []) + queryItems
296296+297297+ guard let fullAuthUrl = components.url else {
298298+ throw OAuthClientError.catchAll("Failed to construct authorization URL")
299299+ }
300300+301301+ return fullAuthUrl
302302+ } catch let error as OAuthClientError {
303303+ throw error
304304+ }catch {
305305+ throw OAuthClientError.unknownError(error)
306306+ }
307307+308308+309309+// public func callback(, resourceUrl: URL? = nil) async throws -> URL {
310310+// //Need to take the redirected url code and state
311311+// //Use the state/sessionId to load teh dpop key we started with and load it as the identity/did coming back
312312+// }
313313+ }
314314+}
315315+
+476
Sources/Gulliver/OAuthTypes.swift
···11+//
22+// File.swift
33+// Gulliver
44+//
55+// Created by Bailey Townsend on 1/20/26.
66+//
77+88+import Foundation
99+1010+1111+1212+/// OAuth 2.0 Client Metadata
1313+/// Based on RFC 7591 Section 2 and related specifications
1414+struct OAuthClientMetadata: Codable {
1515+ /// REQUIRED. Array of redirection URIs for use in redirect-based flows
1616+ let redirectUris: [URL]
1717+1818+ /// OPTIONAL. Array of OAuth 2.0 response_type values that the client will restrict itself to using
1919+ let responseTypes: [String]?
2020+2121+ /// OPTIONAL. Array of OAuth 2.0 grant types that the client will restrict itself to using
2222+ let grantTypes: [String]?
2323+2424+ /// OPTIONAL. String containing a space-separated list of scope values
2525+ let scope: String?
2626+2727+ /// OPTIONAL. Indicator of the requested authentication method for the token endpoint
2828+ let tokenEndpointAuthMethod: String?
2929+3030+ /// OPTIONAL. JWS algorithm that must be used for signing request objects
3131+ let tokenEndpointAuthSigningAlg: String?
3232+3333+ /// OPTIONAL. JWS algorithm required for signing UserInfo Responses
3434+ let userinfoSignedResponseAlg: String?
3535+3636+ /// OPTIONAL. JWE algorithm required for encrypting UserInfo Responses
3737+ let userinfoEncryptedResponseAlg: String?
3838+3939+ /// OPTIONAL. URL string referencing the client's JSON Web Key (JWK) Set document
4040+ let jwksUri: URL?
4141+4242+ /// OPTIONAL. Client's JSON Web Key Set document value
4343+ let jwks: [String: Any]?
4444+4545+ /// OPTIONAL. Kind of application: "web" or "native"
4646+ let applicationType: String?
4747+4848+ /// OPTIONAL. Subject type requested for responses to this client: "public" or "pairwise"
4949+ let subjectType: String?
5050+5151+ /// OPTIONAL. JWS algorithm that must be used for signing Request Objects
5252+ let requestObjectSigningAlg: String?
5353+5454+ /// OPTIONAL. JWS algorithm required for signing the ID Token issued to this client
5555+ let idTokenSignedResponseAlg: String?
5656+5757+ /// OPTIONAL. JWS algorithm required for signing authorization responses
5858+ let authorizationSignedResponseAlg: String?
5959+6060+ /// OPTIONAL. JWE encryption encoding for authorization responses
6161+ let authorizationEncryptedResponseEnc: String?
6262+6363+ /// OPTIONAL. JWE algorithm required for encrypting authorization responses
6464+ let authorizationEncryptedResponseAlg: String?
6565+6666+ /// OPTIONAL. Unique client identifier
6767+ let clientId: String?
6868+6969+ /// OPTIONAL. Human-readable name of the client
7070+ let clientName: String?
7171+7272+ /// OPTIONAL. URL of the home page of the client
7373+ let clientUri: URL?
7474+7575+ /// OPTIONAL. URL that the client provides to the end-user to read about how the profile data will be used
7676+ let policyUri: URL?
7777+7878+ /// OPTIONAL. URL that the client provides to the end-user to read about the client's terms of service
7979+ let tosUri: URL?
8080+8181+ /// OPTIONAL. URL that references a logo for the client application
8282+ let logoUri: URL?
8383+8484+ /// OPTIONAL. Default Maximum Authentication Age in seconds
8585+ /// Specifies that the End-User MUST be actively authenticated if the End-User was authenticated
8686+ /// longer ago than the specified number of seconds
8787+ let defaultMaxAge: Int?
8888+8989+ /// OPTIONAL. Whether the auth_time Claim in the ID Token is REQUIRED
9090+ let requireAuthTime: Bool?
9191+9292+ /// OPTIONAL. Array of email addresses of people responsible for this client
9393+ let contacts: [String]?
9494+9595+ /// OPTIONAL. Whether TLS client certificate bound access tokens are requested
9696+ let tlsClientCertificateBoundAccessTokens: Bool?
9797+9898+ /// OPTIONAL. Whether DPoP-bound access tokens are requested (RFC 9449 Section 5.2)
9999+ let dpopBoundAccessTokens: Bool?
100100+101101+ /// OPTIONAL. Array of authorization details types supported (RFC 9396 Section 14.5)
102102+ let authorizationDetailsTypes: [String]?
103103+104104+ enum CodingKeys: String, CodingKey {
105105+ case redirectUris = "redirect_uris"
106106+ case responseTypes = "response_types"
107107+ case grantTypes = "grant_types"
108108+ case scope
109109+ case tokenEndpointAuthMethod = "token_endpoint_auth_method"
110110+ case tokenEndpointAuthSigningAlg = "token_endpoint_auth_signing_alg"
111111+ case userinfoSignedResponseAlg = "userinfo_signed_response_alg"
112112+ case userinfoEncryptedResponseAlg = "userinfo_encrypted_response_alg"
113113+ case jwksUri = "jwks_uri"
114114+ case jwks
115115+ case applicationType = "application_type"
116116+ case subjectType = "subject_type"
117117+ case requestObjectSigningAlg = "request_object_signing_alg"
118118+ case idTokenSignedResponseAlg = "id_token_signed_response_alg"
119119+ case authorizationSignedResponseAlg = "authorization_signed_response_alg"
120120+ case authorizationEncryptedResponseEnc = "authorization_encrypted_response_enc"
121121+ case authorizationEncryptedResponseAlg = "authorization_encrypted_response_alg"
122122+ case clientId = "client_id"
123123+ case clientName = "client_name"
124124+ case clientUri = "client_uri"
125125+ case policyUri = "policy_uri"
126126+ case tosUri = "tos_uri"
127127+ case logoUri = "logo_uri"
128128+ case defaultMaxAge = "default_max_age"
129129+ case requireAuthTime = "require_auth_time"
130130+ case contacts
131131+ case tlsClientCertificateBoundAccessTokens = "tls_client_certificate_bound_access_tokens"
132132+ case dpopBoundAccessTokens = "dpop_bound_access_tokens"
133133+ case authorizationDetailsTypes = "authorization_details_types"
134134+ }
135135+136136+ // Custom decoder to handle the jwks field which can contain arbitrary JSON
137137+ init(from decoder: Decoder) throws {
138138+ let container = try decoder.container(keyedBy: CodingKeys.self)
139139+140140+ redirectUris = try container.decode([URL].self, forKey: .redirectUris)
141141+ responseTypes = try container.decodeIfPresent([String].self, forKey: .responseTypes)
142142+ grantTypes = try container.decodeIfPresent([String].self, forKey: .grantTypes)
143143+ scope = try container.decodeIfPresent(String.self, forKey: .scope)
144144+ tokenEndpointAuthMethod = try container.decodeIfPresent(String.self, forKey: .tokenEndpointAuthMethod)
145145+ tokenEndpointAuthSigningAlg = try container.decodeIfPresent(String.self, forKey: .tokenEndpointAuthSigningAlg)
146146+ userinfoSignedResponseAlg = try container.decodeIfPresent(String.self, forKey: .userinfoSignedResponseAlg)
147147+ userinfoEncryptedResponseAlg = try container.decodeIfPresent(String.self, forKey: .userinfoEncryptedResponseAlg)
148148+ jwksUri = try container.decodeIfPresent(URL.self, forKey: .jwksUri)
149149+150150+ // Decode jwks as generic dictionary
151151+ if let jwksData = try? container.decodeIfPresent(Data.self, forKey: .jwks),
152152+ let jwksDict = try? JSONSerialization.jsonObject(with: jwksData) as? [String: Any] {
153153+ jwks = jwksDict
154154+ } else {
155155+ jwks = nil
156156+ }
157157+158158+ applicationType = try container.decodeIfPresent(String.self, forKey: .applicationType)
159159+ subjectType = try container.decodeIfPresent(String.self, forKey: .subjectType)
160160+ requestObjectSigningAlg = try container.decodeIfPresent(String.self, forKey: .requestObjectSigningAlg)
161161+ idTokenSignedResponseAlg = try container.decodeIfPresent(String.self, forKey: .idTokenSignedResponseAlg)
162162+ authorizationSignedResponseAlg = try container.decodeIfPresent(String.self, forKey: .authorizationSignedResponseAlg)
163163+ authorizationEncryptedResponseEnc = try container.decodeIfPresent(String.self, forKey: .authorizationEncryptedResponseEnc)
164164+ authorizationEncryptedResponseAlg = try container.decodeIfPresent(String.self, forKey: .authorizationEncryptedResponseAlg)
165165+ clientId = try container.decodeIfPresent(String.self, forKey: .clientId)
166166+ clientName = try container.decodeIfPresent(String.self, forKey: .clientName)
167167+ clientUri = try container.decodeIfPresent(URL.self, forKey: .clientUri)
168168+ policyUri = try container.decodeIfPresent(URL.self, forKey: .policyUri)
169169+ tosUri = try container.decodeIfPresent(URL.self, forKey: .tosUri)
170170+ logoUri = try container.decodeIfPresent(URL.self, forKey: .logoUri)
171171+ defaultMaxAge = try container.decodeIfPresent(Int.self, forKey: .defaultMaxAge)
172172+ requireAuthTime = try container.decodeIfPresent(Bool.self, forKey: .requireAuthTime)
173173+ contacts = try container.decodeIfPresent([String].self, forKey: .contacts)
174174+ tlsClientCertificateBoundAccessTokens = try container.decodeIfPresent(Bool.self, forKey: .tlsClientCertificateBoundAccessTokens)
175175+ dpopBoundAccessTokens = try container.decodeIfPresent(Bool.self, forKey: .dpopBoundAccessTokens)
176176+ authorizationDetailsTypes = try container.decodeIfPresent([String].self, forKey: .authorizationDetailsTypes)
177177+ }
178178+179179+ // Custom encoder to handle the jwks field
180180+ func encode(to encoder: Encoder) throws {
181181+ var container = encoder.container(keyedBy: CodingKeys.self)
182182+183183+ try container.encode(redirectUris, forKey: .redirectUris)
184184+ try container.encodeIfPresent(responseTypes, forKey: .responseTypes)
185185+ try container.encodeIfPresent(grantTypes, forKey: .grantTypes)
186186+ try container.encodeIfPresent(scope, forKey: .scope)
187187+ try container.encodeIfPresent(tokenEndpointAuthMethod, forKey: .tokenEndpointAuthMethod)
188188+ try container.encodeIfPresent(tokenEndpointAuthSigningAlg, forKey: .tokenEndpointAuthSigningAlg)
189189+ try container.encodeIfPresent(userinfoSignedResponseAlg, forKey: .userinfoSignedResponseAlg)
190190+ try container.encodeIfPresent(userinfoEncryptedResponseAlg, forKey: .userinfoEncryptedResponseAlg)
191191+ try container.encodeIfPresent(jwksUri, forKey: .jwksUri)
192192+193193+ // Encode jwks as generic dictionary
194194+ if let jwks = jwks,
195195+ let jwksData = try? JSONSerialization.data(withJSONObject: jwks) {
196196+ try container.encode(jwksData, forKey: .jwks)
197197+ }
198198+199199+ try container.encodeIfPresent(applicationType, forKey: .applicationType)
200200+ try container.encodeIfPresent(subjectType, forKey: .subjectType)
201201+ try container.encodeIfPresent(requestObjectSigningAlg, forKey: .requestObjectSigningAlg)
202202+ try container.encodeIfPresent(idTokenSignedResponseAlg, forKey: .idTokenSignedResponseAlg)
203203+ try container.encodeIfPresent(authorizationSignedResponseAlg, forKey: .authorizationSignedResponseAlg)
204204+ try container.encodeIfPresent(authorizationEncryptedResponseEnc, forKey: .authorizationEncryptedResponseEnc)
205205+ try container.encodeIfPresent(authorizationEncryptedResponseAlg, forKey: .authorizationEncryptedResponseAlg)
206206+ try container.encodeIfPresent(clientId, forKey: .clientId)
207207+ try container.encodeIfPresent(clientName, forKey: .clientName)
208208+ try container.encodeIfPresent(clientUri, forKey: .clientUri)
209209+ try container.encodeIfPresent(policyUri, forKey: .policyUri)
210210+ try container.encodeIfPresent(tosUri, forKey: .tosUri)
211211+ try container.encodeIfPresent(logoUri, forKey: .logoUri)
212212+ try container.encodeIfPresent(defaultMaxAge, forKey: .defaultMaxAge)
213213+ try container.encodeIfPresent(requireAuthTime, forKey: .requireAuthTime)
214214+ try container.encodeIfPresent(contacts, forKey: .contacts)
215215+ try container.encodeIfPresent(tlsClientCertificateBoundAccessTokens, forKey: .tlsClientCertificateBoundAccessTokens)
216216+ try container.encodeIfPresent(dpopBoundAccessTokens, forKey: .dpopBoundAccessTokens)
217217+ try container.encodeIfPresent(authorizationDetailsTypes, forKey: .authorizationDetailsTypes)
218218+ }
219219+}
220220+221221+/// OAuth 2.0 Authorization Server Metadata
222222+/// Based on RFC 8414 and related specifications
223223+struct OAuthAuthorizationServerMetadata: Codable {
224224+ /// The authorization server's issuer identifier
225225+ let issuer: String
226226+227227+ /// Array of claim types supported
228228+ let claimsSupported: [String]?
229229+230230+ /// Languages and scripts supported for claims
231231+ let claimsLocalesSupported: [String]?
232232+233233+ /// Whether the claims parameter is supported
234234+ let claimsParameterSupported: Bool?
235235+236236+ /// Whether the request parameter is supported
237237+ let requestParameterSupported: Bool?
238238+239239+ /// Whether the request_uri parameter is supported
240240+ let requestUriParameterSupported: Bool?
241241+242242+ /// Whether request_uri values must be pre-registered
243243+ let requireRequestUriRegistration: Bool?
244244+245245+ /// Array of OAuth 2.0 scope values supported
246246+ let scopesSupported: [String]?
247247+248248+ /// Subject identifier types supported
249249+ let subjectTypesSupported: [String]?
250250+251251+ /// Response types supported
252252+ let responseTypesSupported: [String]?
253253+254254+ /// Response modes supported
255255+ let responseModesSupported: [String]?
256256+257257+ /// Grant types supported
258258+ let grantTypesSupported: [String]?
259259+260260+ /// PKCE code challenge methods supported
261261+ let codeChallengeMethodsSupported: [String]?
262262+263263+ /// Languages and scripts supported for UI
264264+ let uiLocalesSupported: [String]?
265265+266266+ /// Algorithms supported for signing ID tokens
267267+ let idTokenSigningAlgValuesSupported: [String]?
268268+269269+ /// Display values supported
270270+ let displayValuesSupported: [String]?
271271+272272+ /// Prompt values supported
273273+ let promptValuesSupported: [String]?
274274+275275+ /// Algorithms supported for signing request objects
276276+ let requestObjectSigningAlgValuesSupported: [String]?
277277+278278+ /// Whether authorization response issuer parameter is supported
279279+ let authorizationResponseIssParameterSupported: Bool?
280280+281281+ /// Authorization details types supported
282282+ let authorizationDetailsTypesSupported: [String]?
283283+284284+ /// Algorithms supported for encrypting request objects
285285+ let requestObjectEncryptionAlgValuesSupported: [String]?
286286+287287+ /// Encryption encodings supported for request objects
288288+ let requestObjectEncryptionEncValuesSupported: [String]?
289289+290290+ /// URL of the authorization server's JWK Set document
291291+ let jwksUri: URL?
292292+293293+ /// URL of the authorization endpoint
294294+ let authorizationEndpoint: URL
295295+296296+ /// URL of the token endpoint
297297+ let tokenEndpoint: URL
298298+299299+ /// Authentication methods supported at token endpoint (RFC 8414 Section 2)
300300+ let tokenEndpointAuthMethodsSupported: [String]?
301301+302302+ /// Signing algorithms supported for token endpoint authentication
303303+ let tokenEndpointAuthSigningAlgValuesSupported: [String]?
304304+305305+ /// URL of the revocation endpoint
306306+ let revocationEndpoint: URL?
307307+308308+ /// Authentication methods supported at revocation endpoint
309309+ let revocationEndpointAuthMethodsSupported: [String]?
310310+311311+ /// Signing algorithms supported for revocation endpoint authentication
312312+ let revocationEndpointAuthSigningAlgValuesSupported: [String]?
313313+314314+ /// URL of the introspection endpoint
315315+ let introspectionEndpoint: URL?
316316+317317+ /// Authentication methods supported at introspection endpoint
318318+ let introspectionEndpointAuthMethodsSupported: [String]?
319319+320320+ /// Signing algorithms supported for introspection endpoint authentication
321321+ let introspectionEndpointAuthSigningAlgValuesSupported: [String]?
322322+323323+ /// URL of the pushed authorization request endpoint
324324+ let pushedAuthorizationRequestEndpoint: URL?
325325+326326+ /// Authentication methods supported at PAR endpoint
327327+ let pushedAuthorizationRequestEndpointAuthMethodsSupported: [String]?
328328+329329+ /// Signing algorithms supported for PAR endpoint authentication
330330+ let pushedAuthorizationRequestEndpointAuthSigningAlgValuesSupported: [String]?
331331+332332+ /// Whether pushed authorization requests are required
333333+ let requirePushedAuthorizationRequests: Bool?
334334+335335+ /// URL of the UserInfo endpoint
336336+ let userinfoEndpoint: URL?
337337+338338+ /// URL of the end session endpoint
339339+ let endSessionEndpoint: URL?
340340+341341+ /// URL of the dynamic client registration endpoint
342342+ let registrationEndpoint: URL?
343343+344344+ /// DPoP signing algorithms supported (RFC 9449 Section 5.1)
345345+ let dpopSigningAlgValuesSupported: [String]?
346346+347347+ /// Protected resource URIs (RFC 9728 Section 4)
348348+ let protectedResources: [URL]?
349349+350350+ /// Whether client ID metadata document is supported
351351+ let clientIdMetadataDocumentSupported: Bool?
352352+353353+ enum CodingKeys: String, CodingKey {
354354+ case issuer
355355+ case claimsSupported = "claims_supported"
356356+ case claimsLocalesSupported = "claims_locales_supported"
357357+ case claimsParameterSupported = "claims_parameter_supported"
358358+ case requestParameterSupported = "request_parameter_supported"
359359+ case requestUriParameterSupported = "request_uri_parameter_supported"
360360+ case requireRequestUriRegistration = "require_request_uri_registration"
361361+ case scopesSupported = "scopes_supported"
362362+ case subjectTypesSupported = "subject_types_supported"
363363+ case responseTypesSupported = "response_types_supported"
364364+ case responseModesSupported = "response_modes_supported"
365365+ case grantTypesSupported = "grant_types_supported"
366366+ case codeChallengeMethodsSupported = "code_challenge_methods_supported"
367367+ case uiLocalesSupported = "ui_locales_supported"
368368+ case idTokenSigningAlgValuesSupported = "id_token_signing_alg_values_supported"
369369+ case displayValuesSupported = "display_values_supported"
370370+ case promptValuesSupported = "prompt_values_supported"
371371+ case requestObjectSigningAlgValuesSupported = "request_object_signing_alg_values_supported"
372372+ case authorizationResponseIssParameterSupported = "authorization_response_iss_parameter_supported"
373373+ case authorizationDetailsTypesSupported = "authorization_details_types_supported"
374374+ case requestObjectEncryptionAlgValuesSupported = "request_object_encryption_alg_values_supported"
375375+ case requestObjectEncryptionEncValuesSupported = "request_object_encryption_enc_values_supported"
376376+ case jwksUri = "jwks_uri"
377377+ case authorizationEndpoint = "authorization_endpoint"
378378+ case tokenEndpoint = "token_endpoint"
379379+ case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported"
380380+ case tokenEndpointAuthSigningAlgValuesSupported = "token_endpoint_auth_signing_alg_values_supported"
381381+ case revocationEndpoint = "revocation_endpoint"
382382+ case revocationEndpointAuthMethodsSupported = "revocation_endpoint_auth_methods_supported"
383383+ case revocationEndpointAuthSigningAlgValuesSupported = "revocation_endpoint_auth_signing_alg_values_supported"
384384+ case introspectionEndpoint = "introspection_endpoint"
385385+ case introspectionEndpointAuthMethodsSupported = "introspection_endpoint_auth_methods_supported"
386386+ case introspectionEndpointAuthSigningAlgValuesSupported = "introspection_endpoint_auth_signing_alg_values_supported"
387387+ case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint"
388388+ case pushedAuthorizationRequestEndpointAuthMethodsSupported = "pushed_authorization_request_endpoint_auth_methods_supported"
389389+ case pushedAuthorizationRequestEndpointAuthSigningAlgValuesSupported = "pushed_authorization_request_endpoint_auth_signing_alg_values_supported"
390390+ case requirePushedAuthorizationRequests = "require_pushed_authorization_requests"
391391+ case userinfoEndpoint = "userinfo_endpoint"
392392+ case endSessionEndpoint = "end_session_endpoint"
393393+ case registrationEndpoint = "registration_endpoint"
394394+ case dpopSigningAlgValuesSupported = "dpop_signing_alg_values_supported"
395395+ case protectedResources = "protected_resources"
396396+ case clientIdMetadataDocumentSupported = "client_id_metadata_document_supported"
397397+ }
398398+}
399399+400400+/// OAuth 2.0 Protected Resource Metadata
401401+/// Based on RFC 9728 Section 3.2
402402+/// - SeeAlso: [RFC 9728 Section 3.2](https://www.rfc-editor.org/rfc/rfc9728.html#section-3.2)
403403+struct OAuthProtectedResourceMetadata: Codable {
404404+ /// REQUIRED. The protected resource's resource identifier, which is a URL that
405405+ /// uses the https scheme and has no query or fragment components.
406406+ let resource: URL
407407+408408+ /// OPTIONAL. JSON array containing a list of OAuth authorization server issuer
409409+ /// identifiers, as defined in RFC8414, for authorization servers that can be
410410+ /// used with this protected resource.
411411+ let authorizationServers: [String]?
412412+413413+ /// OPTIONAL. URL of the protected resource's JWK Set document.
414414+ let jwksUri: URL?
415415+416416+ /// RECOMMENDED. JSON array containing a list of the OAuth 2.0 scope values that
417417+ /// are used in authorization requests to request access to this protected resource.
418418+ let scopesSupported: [String]?
419419+420420+ /// OPTIONAL. JSON array containing a list of the supported methods of sending
421421+ /// an OAuth 2.0 Bearer Token to the protected resource.
422422+ let bearerMethodsSupported: [String]?
423423+424424+ /// OPTIONAL. JSON array containing a list of the JWS signing algorithms
425425+ /// supported by the protected resource for signing resource responses.
426426+ let resourceSigningAlgValuesSupported: [String]?
427427+428428+ /// OPTIONAL. URL of a page containing human-readable information that
429429+ /// developers might want or need to know when using the protected resource.
430430+ let resourceDocumentation: URL?
431431+432432+ /// OPTIONAL. URL that the protected resource provides to read about the
433433+ /// protected resource's requirements on how the client can use the data.
434434+ let resourcePolicyUri: URL?
435435+436436+ /// OPTIONAL. URL that the protected resource provides to read about the
437437+ /// protected resource's terms of service.
438438+ let resourceTosUri: URL?
439439+440440+ enum CodingKeys: String, CodingKey {
441441+ case resource
442442+ case authorizationServers = "authorization_servers"
443443+ case jwksUri = "jwks_uri"
444444+ case scopesSupported = "scopes_supported"
445445+ case bearerMethodsSupported = "bearer_methods_supported"
446446+ case resourceSigningAlgValuesSupported = "resource_signing_alg_values_supported"
447447+ case resourceDocumentation = "resource_documentation"
448448+ case resourcePolicyUri = "resource_policy_uri"
449449+ case resourceTosUri = "resource_tos_uri"
450450+ }
451451+}
452452+453453+/// OAuth 2.0 Pushed Authorization Request (PAR) Response
454454+/// Based on RFC 9126 Section 2.2
455455+/// - SeeAlso: [RFC 9126 Section 2.2](https://www.rfc-editor.org/rfc/rfc9126.html#section-2.2)
456456+struct OAuthPushedAuthorizationResponse: Codable {
457457+ /// REQUIRED. The request URI corresponding to the authorization request posted.
458458+ /// This URI is a single-use reference to the respective request data in the
459459+ /// subsequent authorization request. The way the authorization process obtains
460460+ /// the authorization request data is at the discretion of the authorization server
461461+ /// and is out of scope of this specification.
462462+ let requestUri: String
463463+464464+ /// REQUIRED. A JSON number that represents the lifetime of the request URI in seconds.
465465+ /// The request URI lifetime is at the discretion of the authorization server but
466466+ /// will typically be relatively short (e.g., between 5 and 600 seconds).
467467+ let expiresIn: Int
468468+469469+ enum CodingKeys: String, CodingKey {
470470+ case requestUri = "request_uri"
471471+ case expiresIn = "expires_in"
472472+ }
473473+}
474474+475475+476476+