this repo has no description
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

implement oauth

+1535 -29
+5 -1
Sources/CoreATProtocol/APEnvironment.swift
··· 14 14 public var refreshToken: String? 15 15 public var atProtocoldelegate: CoreATProtocolDelegate? 16 16 public let routerDelegate = APRouterDelegate() 17 + public var oauthManager: OAuthManager? { 18 + didSet { 19 + routerDelegate.oauthManager = oauthManager 20 + } 21 + } 17 22 18 23 private init() {} 19 24 ··· 23 28 // self.userAgent = userAgent 24 29 // } 25 30 } 26 -
+39
Sources/CoreATProtocol/CoreATProtocol.swift
··· 26 26 public func update(hostURL: String?) { 27 27 APEnvironment.current.host = hostURL 28 28 } 29 + 30 + @APActor 31 + public func configureOAuth( 32 + configuration: OAuthConfiguration, 33 + credentialStore: OAuthCredentialStore? = nil 34 + ) async throws { 35 + let store = credentialStore ?? InMemoryOAuthCredentialStore() 36 + let manager = try await OAuthManager(configuration: configuration, credentialStore: store) 37 + APEnvironment.current.oauthManager = manager 38 + } 39 + 40 + @APActor 41 + public func authenticate(handle: String, using uiProvider: OAuthUIProvider) async throws -> OAuthSession { 42 + guard let manager = APEnvironment.current.oauthManager else { 43 + throw OAuthManagerError.invalidAuthorizationState 44 + } 45 + let session = try await manager.authenticate(handle: handle, using: uiProvider) 46 + APEnvironment.current.host = session.pdsURL.absoluteString 47 + return session 48 + } 49 + 50 + @APActor 51 + public func currentOAuthSession() -> OAuthSession? { 52 + APEnvironment.current.oauthManager?.currentSession 53 + } 54 + 55 + @APActor 56 + public func refreshOAuthSession() async throws -> OAuthSession { 57 + guard let manager = APEnvironment.current.oauthManager else { 58 + throw OAuthManagerError.invalidAuthorizationState 59 + } 60 + return try await manager.refreshSession() 61 + } 62 + 63 + @APActor 64 + public func signOutOAuth() async throws { 65 + guard let manager = APEnvironment.current.oauthManager else { return } 66 + try await manager.signOut() 67 + }
+97 -28
Sources/CoreATProtocol/Networking.swift
··· 31 31 } 32 32 33 33 @APActor 34 - public class APRouterDelegate: NetworkRouterDelegate { 35 - private var shouldRefreshToken = false 36 - 34 + public final class APRouterDelegate: NetworkRouterDelegate { 35 + public var oauthManager: OAuthManager? { 36 + didSet { pendingRetryAction = .none } 37 + } 38 + 39 + private enum RetryAction { 40 + case none 41 + case refreshToken 42 + case regenerateDPoP 43 + } 44 + 45 + private var pendingRetryAction: RetryAction = .none 46 + 37 47 public func intercept(_ request: inout URLRequest) async { 38 - if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken { 39 - shouldRefreshToken = false 40 - request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization") 41 - } else if let accessToken = APEnvironment.current.accessToken { 48 + if let manager = oauthManager { 49 + do { 50 + try await manager.authenticateResourceRequest(&request) 51 + return 52 + } catch { 53 + // Fall back to legacy bearer injection if OAuth authentication fails. 54 + } 55 + } 56 + 57 + if let accessToken = APEnvironment.current.accessToken { 42 58 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 43 59 } 44 60 } 45 - 61 + 46 62 public func shouldRetry(error: Error, attempts: Int) async throws -> Bool { 47 - func getNewToken() async throws -> Bool { 48 - // shouldRefreshToken = true 49 - // let newSession = try await AtProtoLexicons().refresh(attempts: attempts + 1) 50 - // APEnvironment.current.accessToken = newSession.accessJwt 51 - // APEnvironment.current.refreshToken = newSession.refreshJwt 52 - // await delegate?.sessionUpdated(newSession) 53 - // 54 - // return true 55 - false 63 + if let manager = oauthManager { 64 + switch pendingRetryAction { 65 + case .regenerateDPoP where attempts < 3: 66 + pendingRetryAction = .none 67 + return true 68 + case .refreshToken: 69 + pendingRetryAction = .none 70 + do { 71 + _ = try await manager.refreshSession(force: true) 72 + return true 73 + } catch { 74 + return false 75 + } 76 + default: 77 + pendingRetryAction = .none 78 + } 79 + } 80 + 81 + if case .message(let message) = error as? AtError, 82 + message.error == AtErrorType.expiredToken.rawValue { 83 + return false 84 + } 85 + 86 + return false 87 + } 88 + 89 + public func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async { 90 + guard let manager = oauthManager else { return } 91 + 92 + if let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false { 93 + await manager.updateResourceServerNonce(nonce) 56 94 } 57 - 58 - // TODO: verify this works! 59 - if case .network(let networkError) = error as? AtError, 60 - case .statusCode(let statusCode, _) = networkError, 61 - let statusCode = statusCode?.rawValue, (400..<500).contains(statusCode), 62 - attempts == 1 { 63 - return try await getNewToken() 64 - } else if case .message(let message) = error as? AtError, 65 - message.error == AtErrorType.expiredToken.rawValue { 66 - return try await getNewToken() 95 + 96 + guard (400..<500).contains(response.statusCode) else { 97 + pendingRetryAction = .none 98 + return 99 + } 100 + 101 + if containsUseDPoPNonce(response: response, data: data) { 102 + pendingRetryAction = .regenerateDPoP 103 + return 67 104 } 68 - 105 + 106 + if containsInvalidToken(response: response, data: data) { 107 + pendingRetryAction = .refreshToken 108 + return 109 + } 110 + 111 + pendingRetryAction = .none 112 + } 113 + 114 + private func containsUseDPoPNonce(response: HTTPURLResponse, data: Data) -> Bool { 115 + if header(response, containsError: "use_dpop_nonce") { 116 + return true 117 + } 118 + if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data), 119 + errorResponse.error == "use_dpop_nonce" { 120 + return true 121 + } 69 122 return false 123 + } 124 + 125 + private func containsInvalidToken(response: HTTPURLResponse, data: Data) -> Bool { 126 + if header(response, containsError: "invalid_token") { 127 + return true 128 + } 129 + if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data), 130 + errorResponse.error == "invalid_token" { 131 + return true 132 + } 133 + return false 134 + } 135 + 136 + private func header(_ response: HTTPURLResponse, containsError token: String) -> Bool { 137 + guard let header = response.value(forHTTPHeaderField: "WWW-Authenticate") else { return false } 138 + return header.range(of: "error=\"\(token)\"", options: .caseInsensitive) != nil || header.range(of: "error=\(token)", options: .caseInsensitive) != nil 70 139 } 71 140 }
+6
Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift
··· 4 4 public protocol NetworkRouterDelegate: AnyObject { 5 5 func intercept(_ request: inout URLRequest) async 6 6 func shouldRetry(error: Error, attempts: Int) async throws -> Bool 7 + func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async 8 + } 9 + 10 + extension NetworkRouterDelegate { 11 + public func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async {} 7 12 } 8 13 9 14 /// Describes the implementation details of a NetworkRouter ··· 63 68 64 69 let (data, response) = try await networking.data(for: request, delegate: urlSessionTaskDelegate) 65 70 guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.noStatusCode } 71 + await delegate?.didReceive(response: httpResponse, data: data, for: request) 66 72 switch httpResponse.statusCode { 67 73 case 200...299: 68 74 return try decoder.decode(T.self, from: data)
+54
Sources/CoreATProtocol/OAuth/Identity/DNSResolver.swift
··· 1 + import Foundation 2 + 3 + enum DNSResolverError: Error, Sendable { 4 + case invalidResponse 5 + } 6 + 7 + protocol DNSResolving: Sendable { 8 + func txtRecords(for host: String) async throws -> [String] 9 + } 10 + 11 + @APActor 12 + final class DoHDNSResolver: DNSResolving { 13 + private let baseURL: URL 14 + private let httpClient: OAuthHTTPClient 15 + 16 + init(baseURL: URL = URL(string: "https://cloudflare-dns.com/dns-query")!, httpClient: OAuthHTTPClient = OAuthHTTPClient()) { 17 + self.baseURL = baseURL 18 + self.httpClient = httpClient 19 + } 20 + 21 + func txtRecords(for host: String) async throws -> [String] { 22 + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { 23 + throw DNSResolverError.invalidResponse 24 + } 25 + var queryItems = components.queryItems ?? [] 26 + queryItems.append(URLQueryItem(name: "name", value: host)) 27 + queryItems.append(URLQueryItem(name: "type", value: "TXT")) 28 + components.queryItems = queryItems 29 + guard let url = components.url else { throw DNSResolverError.invalidResponse } 30 + var request = URLRequest(url: url) 31 + request.setValue("application/dns-json", forHTTPHeaderField: "Accept") 32 + let (data, _) = try await httpClient.send(request) 33 + let response = try httpClient.decodeJSON(DNSResponse.self, from: data) 34 + return response.answers?.compactMap { $0.txtValue } ?? [] 35 + } 36 + 37 + private struct DNSResponse: Decodable { 38 + struct Answer: Decodable { 39 + let data: String 40 + 41 + var txtValue: String? { 42 + guard data.count >= 2 else { return nil } 43 + var trimmed = data 44 + if trimmed.hasPrefix("\"") && trimmed.hasSuffix("\"") { 45 + trimmed.removeFirst() 46 + trimmed.removeLast() 47 + } 48 + return trimmed 49 + } 50 + } 51 + 52 + let answers: [Answer]? 53 + } 54 + }
+134
Sources/CoreATProtocol/OAuth/Identity/IdentityResolver.swift
··· 1 + import Foundation 2 + 3 + enum IdentityResolverError: Error, Sendable { 4 + case unableToResolveHandle 5 + case invalidDID 6 + case unsupportedDIDMethod 7 + case missingPDSService 8 + } 9 + 10 + @APActor 11 + final class IdentityResolver: Sendable { 12 + private let httpClient: OAuthHTTPClient 13 + private let dnsResolver: DNSResolving 14 + 15 + init(httpClient: OAuthHTTPClient = OAuthHTTPClient(), dnsResolver: DNSResolving = DoHDNSResolver()) { 16 + self.httpClient = httpClient 17 + self.dnsResolver = dnsResolver 18 + } 19 + 20 + func resolveHandle(_ handle: String) async throws -> String { 21 + if handle.lowercased().hasPrefix("did:") { 22 + return handle 23 + } 24 + 25 + if let did = try? await resolveViaHTTPS(handle: handle) { 26 + return did 27 + } 28 + 29 + if let did = try? await resolveViaDNS(handle: handle) { 30 + return did 31 + } 32 + 33 + throw IdentityResolverError.unableToResolveHandle 34 + } 35 + 36 + func fetchDIDDocument(for did: String) async throws -> DIDDocument { 37 + if did.hasPrefix("did:plc:") { 38 + let identifier = String(did.dropFirst("did:plc:".count)) 39 + guard let url = URL(string: "https://plc.directory/\(identifier)") else { 40 + throw IdentityResolverError.invalidDID 41 + } 42 + return try await fetchJSON(url: url, type: DIDDocument.self) 43 + } else if did.hasPrefix("did:web:") { 44 + let components = try webDIDComponents(did: did) 45 + return try await fetchJSON(url: components.url, type: DIDDocument.self) 46 + } else { 47 + throw IdentityResolverError.unsupportedDIDMethod 48 + } 49 + } 50 + 51 + func discoverProtectedResource(for pdsURL: URL) async throws -> OAuthProtectedResourceMetadata { 52 + let endpoint = pdsURL.appendingPathComponent(".well-known/oauth-protected-resource") 53 + return try await fetchJSON(url: endpoint, type: OAuthProtectedResourceMetadata.self) 54 + } 55 + 56 + func fetchAuthorizationServerMetadata(from url: URL) async throws -> OAuthAuthorizationServerMetadata { 57 + let endpoint = url.appendingPathComponent(".well-known/oauth-authorization-server") 58 + return try await fetchJSON(url: endpoint, type: OAuthAuthorizationServerMetadata.self) 59 + } 60 + 61 + func extractPDSEndpoint(from document: DIDDocument) throws -> URL { 62 + guard let service = document.service(ofType: "AtprotoPersonalDataServer"), let url = URL(string: service.serviceEndpoint) else { 63 + throw IdentityResolverError.missingPDSService 64 + } 65 + return url 66 + } 67 + 68 + // MARK: - Private 69 + 70 + private func resolveViaHTTPS(handle: String) async throws -> String? { 71 + var components = URLComponents() 72 + components.scheme = "https" 73 + components.host = handle 74 + components.path = "/.well-known/atproto-did" 75 + guard let url = components.url else { return nil } 76 + var request = URLRequest(url: url) 77 + request.timeoutInterval = 5 78 + let (data, response) = try await httpClient.send(request) 79 + guard (200..<300).contains(response.statusCode) else { return nil } 80 + let did = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) 81 + guard did.isEmpty == false, did.lowercased().hasPrefix("did:") else { return nil } 82 + return did 83 + } 84 + 85 + private func resolveViaDNS(handle: String) async throws -> String? { 86 + let hostname = "_atproto.\(handle)" 87 + let records = try await dnsResolver.txtRecords(for: hostname) 88 + for record in records { 89 + let parts = record.split(separator: "=", maxSplits: 1).map(String.init) 90 + if parts.count == 2, parts[0] == "did" { 91 + return parts[1] 92 + } 93 + } 94 + return nil 95 + } 96 + 97 + private func fetchJSON<T: Decodable>(url: URL, type: T.Type) async throws -> T { 98 + var request = URLRequest(url: url) 99 + request.setValue("application/json", forHTTPHeaderField: "Accept") 100 + let (data, response) = try await httpClient.send(request) 101 + guard (200..<300).contains(response.statusCode) else { 102 + throw IdentityResolverError.invalidDID 103 + } 104 + return try httpClient.decodeJSON(T.self, from: data) 105 + } 106 + 107 + private func webDIDComponents(did: String) throws -> (host: String, pathSegments: [String], url: URL) { 108 + let prefix = "did:web:" 109 + guard did.hasPrefix(prefix) else { throw IdentityResolverError.invalidDID } 110 + let suffix = String(did.dropFirst(prefix.count)) 111 + let segments = suffix.split(separator: ":").map { segment in 112 + segment.removingPercentEncoding ?? String(segment) 113 + } 114 + guard let host = segments.first else { 115 + throw IdentityResolverError.invalidDID 116 + } 117 + let pathSegments = Array(segments.dropFirst()) 118 + var components = URLComponents() 119 + components.scheme = "https" 120 + components.host = host 121 + let path: String 122 + if pathSegments.isEmpty { 123 + path = "/.well-known/did.json" 124 + } else { 125 + let joined = pathSegments.joined(separator: "/") 126 + path = "/\(joined)/did.json" 127 + } 128 + components.path = path 129 + guard let url = components.url else { 130 + throw IdentityResolverError.invalidDID 131 + } 132 + return (host, pathSegments, url) 133 + } 134 + }
+47
Sources/CoreATProtocol/OAuth/Models/DIDDocument.swift
··· 1 + import Foundation 2 + 3 + struct DIDDocument: Decodable, Sendable { 4 + struct Service: Decodable, Sendable { 5 + let id: String 6 + let type: String 7 + let serviceEndpoint: String 8 + 9 + private enum CodingKeys: String, CodingKey { 10 + case id 11 + case type 12 + case serviceEndpoint 13 + } 14 + 15 + init(from decoder: Decoder) throws { 16 + let container = try decoder.container(keyedBy: CodingKeys.self) 17 + self.id = try container.decode(String.self, forKey: .id) 18 + self.type = try container.decode(String.self, forKey: .type) 19 + if let endpoint = try? container.decode(String.self, forKey: .serviceEndpoint) { 20 + self.serviceEndpoint = endpoint 21 + } else if let endpointObject = try? container.decode(ServiceEndpoint.self, forKey: .serviceEndpoint) { 22 + guard let uri = endpointObject.uri else { 23 + throw DecodingError.dataCorruptedError(forKey: .serviceEndpoint, in: container, debugDescription: "Missing uri field in service endpoint object") 24 + } 25 + self.serviceEndpoint = uri 26 + } else { 27 + throw DecodingError.dataCorruptedError(forKey: .serviceEndpoint, in: container, debugDescription: "Unsupported service endpoint type") 28 + } 29 + } 30 + 31 + private struct ServiceEndpoint: Decodable { 32 + let uri: String? 33 + } 34 + } 35 + 36 + let id: String 37 + let services: [Service] 38 + 39 + private enum CodingKeys: String, CodingKey { 40 + case id 41 + case services = "service" 42 + } 43 + 44 + func service(ofType type: String) -> Service? { 45 + services.first { $0.type.localizedCaseInsensitiveCompare(type) == .orderedSame } 46 + } 47 + }
+30
Sources/CoreATProtocol/OAuth/Models/OAuthConfiguration.swift
··· 1 + import Foundation 2 + 3 + public struct OAuthConfiguration: Sendable { 4 + public let clientMetadataURL: URL 5 + public let redirectURI: URL 6 + public let requestedScopes: [String] 7 + public let additionalAuthorizationParameters: [String: String] 8 + 9 + public init( 10 + clientMetadataURL: URL, 11 + redirectURI: URL, 12 + requestedScopes: [String] = ["atproto"], 13 + additionalAuthorizationParameters: [String: String] = [:] 14 + ) { 15 + self.clientMetadataURL = clientMetadataURL 16 + self.redirectURI = redirectURI 17 + var scopes = requestedScopes 18 + if scopes.isEmpty { 19 + scopes = ["atproto"] 20 + } else if scopes.contains("atproto") == false { 21 + scopes.append("atproto") 22 + } 23 + var uniqueScopes: [String] = [] 24 + for scope in scopes where uniqueScopes.contains(scope) == false { 25 + uniqueScopes.append(scope) 26 + } 27 + self.requestedScopes = uniqueScopes 28 + self.additionalAuthorizationParameters = additionalAuthorizationParameters 29 + } 30 + }
+160
Sources/CoreATProtocol/OAuth/Models/OAuthMetadata.swift
··· 1 + import Foundation 2 + 3 + struct OAuthProtectedResourceMetadata: Decodable, Sendable { 4 + let authorizationServers: [URL] 5 + 6 + private enum CodingKeys: String, CodingKey { 7 + case authorizationServers = "authorization_servers" 8 + } 9 + 10 + init(from decoder: Decoder) throws { 11 + let container = try decoder.container(keyedBy: CodingKeys.self) 12 + let values = try container.decodeIfPresent([String].self, forKey: .authorizationServers) ?? [] 13 + self.authorizationServers = try values.map { value in 14 + guard let url = URL(string: value) else { 15 + throw DecodingError.dataCorruptedError(forKey: .authorizationServers, in: container, debugDescription: "Invalid authorization server URL") 16 + } 17 + return url 18 + } 19 + } 20 + } 21 + 22 + struct OAuthAuthorizationServerMetadata: Decodable, Sendable { 23 + let issuer: URL 24 + let authorizationEndpoint: URL 25 + let tokenEndpoint: URL 26 + let pushedAuthorizationRequestEndpoint: URL 27 + let codeChallengeMethodsSupported: [String] 28 + let dPoPSigningAlgValuesSupported: [String] 29 + let scopesSupported: [String] 30 + 31 + private enum CodingKeys: String, CodingKey { 32 + case issuer 33 + case authorizationEndpoint = "authorization_endpoint" 34 + case tokenEndpoint = "token_endpoint" 35 + case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint" 36 + case codeChallengeMethodsSupported = "code_challenge_methods_supported" 37 + case dPoPSigningAlgValuesSupported = "dpop_signing_alg_values_supported" 38 + case scopesSupported = "scopes_supported" 39 + } 40 + 41 + init(from decoder: Decoder) throws { 42 + let container = try decoder.container(keyedBy: CodingKeys.self) 43 + guard let issuer = URL(string: try container.decode(String.self, forKey: .issuer)) else { 44 + throw DecodingError.dataCorruptedError(forKey: .issuer, in: container, debugDescription: "Invalid issuer URL") 45 + } 46 + guard let authorizationEndpoint = URL(string: try container.decode(String.self, forKey: .authorizationEndpoint)) else { 47 + throw DecodingError.dataCorruptedError(forKey: .authorizationEndpoint, in: container, debugDescription: "Invalid authorization endpoint") 48 + } 49 + guard let tokenEndpoint = URL(string: try container.decode(String.self, forKey: .tokenEndpoint)) else { 50 + throw DecodingError.dataCorruptedError(forKey: .tokenEndpoint, in: container, debugDescription: "Invalid token endpoint") 51 + } 52 + guard let parEndpoint = URL(string: try container.decode(String.self, forKey: .pushedAuthorizationRequestEndpoint)) else { 53 + throw DecodingError.dataCorruptedError(forKey: .pushedAuthorizationRequestEndpoint, in: container, debugDescription: "Invalid PAR endpoint") 54 + } 55 + 56 + self.issuer = issuer 57 + self.authorizationEndpoint = authorizationEndpoint 58 + self.tokenEndpoint = tokenEndpoint 59 + self.pushedAuthorizationRequestEndpoint = parEndpoint 60 + self.codeChallengeMethodsSupported = try container.decodeIfPresent([String].self, forKey: .codeChallengeMethodsSupported) ?? [] 61 + self.dPoPSigningAlgValuesSupported = try container.decodeIfPresent([String].self, forKey: .dPoPSigningAlgValuesSupported) ?? [] 62 + self.scopesSupported = try container.decodeIfPresent([String].self, forKey: .scopesSupported) ?? [] 63 + } 64 + } 65 + 66 + struct OAuthClientMetadata: Decodable, Sendable { 67 + let clientID: URL 68 + let scope: String 69 + let redirectURIs: [URL] 70 + let grantTypes: [String] 71 + let responseTypes: [String] 72 + let tokenEndpointAuthMethod: String 73 + let tokenEndpointAuthSigningAlg: String? 74 + let dPoPBoundAccessTokens: Bool 75 + 76 + private enum CodingKeys: String, CodingKey { 77 + case clientID = "client_id" 78 + case scope 79 + case redirectURIs = "redirect_uris" 80 + case grantTypes = "grant_types" 81 + case responseTypes = "response_types" 82 + case tokenEndpointAuthMethod = "token_endpoint_auth_method" 83 + case tokenEndpointAuthSigningAlg = "token_endpoint_auth_signing_alg" 84 + case dPoPBoundAccessTokens = "dpop_bound_access_tokens" 85 + } 86 + 87 + init(from decoder: Decoder) throws { 88 + let container = try decoder.container(keyedBy: CodingKeys.self) 89 + guard let clientID = URL(string: try container.decode(String.self, forKey: .clientID)) else { 90 + throw DecodingError.dataCorruptedError(forKey: .clientID, in: container, debugDescription: "Invalid client metadata URL") 91 + } 92 + self.clientID = clientID 93 + self.scope = try container.decode(String.self, forKey: .scope) 94 + let redirectStrings = try container.decode([String].self, forKey: .redirectURIs) 95 + self.redirectURIs = try redirectStrings.map { value in 96 + guard let url = URL(string: value) else { 97 + throw DecodingError.dataCorruptedError(forKey: .redirectURIs, in: container, debugDescription: "Invalid redirect URI") 98 + } 99 + return url 100 + } 101 + self.grantTypes = try container.decode([String].self, forKey: .grantTypes) 102 + self.responseTypes = try container.decode([String].self, forKey: .responseTypes) 103 + self.tokenEndpointAuthMethod = try container.decode(String.self, forKey: .tokenEndpointAuthMethod) 104 + self.tokenEndpointAuthSigningAlg = try container.decodeIfPresent(String.self, forKey: .tokenEndpointAuthSigningAlg) 105 + self.dPoPBoundAccessTokens = try container.decode(Bool.self, forKey: .dPoPBoundAccessTokens) 106 + } 107 + } 108 + 109 + struct OAuthTokenResponse: Decodable, Sendable { 110 + let accessToken: String 111 + let refreshToken: String? 112 + let tokenType: String 113 + let expiresIn: TimeInterval? 114 + let scope: String? 115 + let issuedTokenType: String? 116 + let subject: String? 117 + 118 + private enum CodingKeys: String, CodingKey { 119 + case accessToken = "access_token" 120 + case refreshToken = "refresh_token" 121 + case tokenType = "token_type" 122 + case expiresIn = "expires_in" 123 + case scope 124 + case issuedTokenType = "issued_token_type" 125 + case subject = "sub" 126 + } 127 + } 128 + 129 + struct PushedAuthorizationRequestResponse: Decodable, Sendable { 130 + let requestURI: String 131 + let expiresIn: Int 132 + 133 + private enum CodingKeys: String, CodingKey { 134 + case requestURI = "request_uri" 135 + case expiresIn = "expires_in" 136 + } 137 + } 138 + 139 + struct OAuthErrorResponse: Decodable, Error, Sendable { 140 + let error: String 141 + let errorDescription: String? 142 + let errorURI: URL? 143 + 144 + private enum CodingKeys: String, CodingKey { 145 + case error 146 + case errorDescription = "error_description" 147 + case errorURI = "error_uri" 148 + } 149 + 150 + init(from decoder: Decoder) throws { 151 + let container = try decoder.container(keyedBy: CodingKeys.self) 152 + self.error = try container.decode(String.self, forKey: .error) 153 + self.errorDescription = try container.decodeIfPresent(String.self, forKey: .errorDescription) 154 + if let raw = try container.decodeIfPresent(String.self, forKey: .errorURI) { 155 + self.errorURI = URL(string: raw) 156 + } else { 157 + self.errorURI = nil 158 + } 159 + } 160 + }
+53
Sources/CoreATProtocol/OAuth/Models/OAuthSession.swift
··· 1 + import Foundation 2 + 3 + public struct OAuthSession: Codable, Sendable { 4 + public let did: String 5 + public let pdsURL: URL 6 + public let authorizationServer: URL 7 + public let tokenEndpoint: URL 8 + public let accessToken: String 9 + public let refreshToken: String 10 + public let tokenType: String 11 + public let scope: String? 12 + public let expiresIn: TimeInterval? 13 + public let issuedAt: Date 14 + 15 + public init( 16 + did: String, 17 + pdsURL: URL, 18 + authorizationServer: URL, 19 + tokenEndpoint: URL, 20 + accessToken: String, 21 + refreshToken: String, 22 + tokenType: String, 23 + scope: String?, 24 + expiresIn: TimeInterval?, 25 + issuedAt: Date 26 + ) { 27 + self.did = did 28 + self.pdsURL = pdsURL 29 + self.authorizationServer = authorizationServer 30 + self.tokenEndpoint = tokenEndpoint 31 + self.accessToken = accessToken 32 + self.refreshToken = refreshToken 33 + self.tokenType = tokenType 34 + self.scope = scope 35 + self.expiresIn = expiresIn 36 + self.issuedAt = issuedAt 37 + } 38 + 39 + public func isExpired(relativeTo date: Date = Date(), tolerance: TimeInterval = 0) -> Bool { 40 + guard let expiresAt else { return false } 41 + return expiresAt <= date.addingTimeInterval(tolerance * -1) 42 + } 43 + 44 + public func needsRefresh(relativeTo date: Date = Date(), threshold: TimeInterval = 300) -> Bool { 45 + guard let expiresAt else { return false } 46 + return expiresAt <= date.addingTimeInterval(threshold) 47 + } 48 + 49 + public var expiresAt: Date? { 50 + guard let expiresIn else { return nil } 51 + return issuedAt.addingTimeInterval(expiresIn) 52 + } 53 + }
+34
Sources/CoreATProtocol/OAuth/Networking/OAuthHTTPClient.swift
··· 1 + import Foundation 2 + 3 + enum OAuthNetworkingError: Error, Sendable { 4 + case invalidResponse 5 + } 6 + 7 + @APActor 8 + final class OAuthHTTPClient: Sendable { 9 + private let networking: Networking 10 + private let jsonDecoder: JSONDecoder 11 + 12 + init(networking: Networking = URLSession.shared, decoder: JSONDecoder? = nil) { 13 + self.networking = networking 14 + if let decoder { 15 + self.jsonDecoder = decoder 16 + } else { 17 + let decoder = JSONDecoder() 18 + decoder.keyDecodingStrategy = .convertFromSnakeCase 19 + self.jsonDecoder = decoder 20 + } 21 + } 22 + 23 + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { 24 + let (data, response) = try await networking.data(for: request, delegate: nil) 25 + guard let httpResponse = response as? HTTPURLResponse else { 26 + throw OAuthNetworkingError.invalidResponse 27 + } 28 + return (data, httpResponse) 29 + } 30 + 31 + func decodeJSON<T: Decodable>(_ type: T.Type, from data: Data) throws -> T { 32 + try jsonDecoder.decode(T.self, from: data) 33 + } 34 + }
+507
Sources/CoreATProtocol/OAuth/OAuthManager.swift
··· 1 + import Foundation 2 + 3 + public enum OAuthManagerError: Error, Sendable { 4 + case missingAuthorizationServer 5 + case invalidAuthorizationState 6 + case authorizationInProgress 7 + case callbackStateMismatch 8 + case authorizationCancelled 9 + case tokenExchangeFailed 10 + case refreshFailed 11 + case invalidRedirectURL 12 + case unsupportedAuthorizationServer 13 + case clientMetadataValidationFailed 14 + case identityResolutionFailed 15 + case missingSession 16 + case invalidRequest 17 + } 18 + 19 + public struct AuthorizationRequest: Sendable { 20 + public let authorizationURL: URL 21 + public let redirectURI: URL 22 + } 23 + 24 + @APActor 25 + public final class OAuthManager: Sendable { 26 + private let configuration: OAuthConfiguration 27 + private let credentialStore: OAuthCredentialStore 28 + private let identityResolver: IdentityResolver 29 + private let httpClient: OAuthHTTPClient 30 + private let randomGenerator: RandomDataGenerating 31 + private var dpopGenerator: DPoPGenerator 32 + 33 + private var cachedClientMetadata: OAuthClientMetadata? 34 + private var pendingAuthorization: PendingAuthorization? 35 + private var cachedSession: OAuthSession? 36 + private var authorizationServerNonce: String? 37 + private var resourceServerNonce: String? 38 + 39 + init( 40 + configuration: OAuthConfiguration, 41 + credentialStore: OAuthCredentialStore, 42 + identityResolver: IdentityResolver = IdentityResolver(), 43 + httpClient: OAuthHTTPClient = OAuthHTTPClient(), 44 + randomGenerator: RandomDataGenerating = SecureRandomDataGenerator() 45 + ) async throws { 46 + self.configuration = configuration 47 + self.credentialStore = credentialStore 48 + self.identityResolver = identityResolver 49 + self.httpClient = httpClient 50 + self.randomGenerator = randomGenerator 51 + 52 + if let keyData = try await credentialStore.loadDPoPKey(), 53 + (try? DPoPKeyPair(rawRepresentation: keyData)) != nil { 54 + self.dpopGenerator = DPoPGenerator(keyPair: try DPoPKeyPair(rawRepresentation: keyData)) 55 + } else { 56 + let keyPair = DPoPKeyPair() 57 + self.dpopGenerator = DPoPGenerator(keyPair: keyPair) 58 + try await credentialStore.saveDPoPKey(keyPair.export()) 59 + } 60 + 61 + self.cachedSession = try await credentialStore.loadSession() 62 + } 63 + 64 + public convenience init( 65 + configuration: OAuthConfiguration, 66 + credentialStore: OAuthCredentialStore 67 + ) async throws { 68 + try await self.init( 69 + configuration: configuration, 70 + credentialStore: credentialStore, 71 + identityResolver: IdentityResolver(), 72 + httpClient: OAuthHTTPClient(), 73 + randomGenerator: SecureRandomDataGenerator() 74 + ) 75 + } 76 + 77 + public var currentSession: OAuthSession? { 78 + cachedSession 79 + } 80 + 81 + public func authenticateResourceRequest(_ request: inout URLRequest) async throws { 82 + guard let url = request.url else { throw OAuthManagerError.invalidRequest } 83 + guard var session = cachedSession else { throw OAuthManagerError.missingSession } 84 + 85 + if session.needsRefresh() { 86 + session = try await refreshSession(force: true) 87 + } 88 + 89 + let proof = try dpopGenerator.generateProof( 90 + method: request.httpMethod ?? "GET", 91 + url: url, 92 + nonce: resourceServerNonce, 93 + accessToken: session.accessToken 94 + ) 95 + 96 + request.setValue("DPoP \(session.accessToken)", forHTTPHeaderField: "Authorization") 97 + request.setValue(proof, forHTTPHeaderField: "DPoP") 98 + } 99 + 100 + public func authenticate(handle: String, using uiProvider: OAuthUIProvider) async throws -> OAuthSession { 101 + let request = try await beginAuthorization(for: handle) 102 + guard let callbackScheme = configuration.redirectURI.scheme else { 103 + throw OAuthManagerError.invalidRedirectURL 104 + } 105 + let callbackURL = try await uiProvider.presentAuthorization(at: request.authorizationURL, callbackScheme: callbackScheme) 106 + return try await resumeAuthorization(from: callbackURL) 107 + } 108 + 109 + public func beginAuthorization(for handle: String) async throws -> AuthorizationRequest { 110 + guard pendingAuthorization == nil else { throw OAuthManagerError.authorizationInProgress } 111 + 112 + let did = try await identityResolver.resolveHandle(handle) 113 + let didDocument = try await identityResolver.fetchDIDDocument(for: did) 114 + let pdsEndpoint = try identityResolver.extractPDSEndpoint(from: didDocument) 115 + 116 + let protectedMetadata = try await identityResolver.discoverProtectedResource(for: pdsEndpoint) 117 + guard let authorizationServerURL = protectedMetadata.authorizationServers.first else { 118 + throw OAuthManagerError.missingAuthorizationServer 119 + } 120 + let authMetadata = try await identityResolver.fetchAuthorizationServerMetadata(from: authorizationServerURL) 121 + try validateAuthorizationServerMetadata(authMetadata) 122 + 123 + let clientMetadata = try await loadClientMetadata() 124 + 125 + let pkce = try PKCEGenerator(randomGenerator: randomGenerator).makeValues() 126 + let state = try generateState() 127 + 128 + let parResult = try await performPushedAuthorizationRequest( 129 + metadata: authMetadata, 130 + clientMetadata: clientMetadata, 131 + handle: handle, 132 + did: did, 133 + pkce: pkce, 134 + state: state 135 + ) 136 + 137 + authorizationServerNonce = parResult.nonce ?? authorizationServerNonce 138 + 139 + let authorizationURL = makeAuthorizationURL( 140 + endpoint: authMetadata.authorizationEndpoint, 141 + clientID: clientMetadata.clientID, 142 + requestURI: parResult.requestURI 143 + ) 144 + 145 + pendingAuthorization = PendingAuthorization( 146 + handle: handle, 147 + did: did, 148 + pdsURL: pdsEndpoint, 149 + authorizationServerMetadata: authMetadata, 150 + clientMetadata: clientMetadata, 151 + state: state, 152 + pkce: pkce, 153 + requestURI: parResult.requestURI, 154 + issuedAt: Date() 155 + ) 156 + 157 + return AuthorizationRequest(authorizationURL: authorizationURL, redirectURI: configuration.redirectURI) 158 + } 159 + 160 + public func resumeAuthorization(from callbackURL: URL) async throws -> OAuthSession { 161 + guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else { 162 + throw OAuthManagerError.invalidRedirectURL 163 + } 164 + guard let pending = pendingAuthorization else { throw OAuthManagerError.invalidAuthorizationState } 165 + defer { pendingAuthorization = nil } 166 + 167 + if components.scheme != configuration.redirectURI.scheme { 168 + throw OAuthManagerError.invalidRedirectURL 169 + } 170 + 171 + let queryItems = components.queryItems ?? [] 172 + if queryItems.contains(where: { $0.name == "error" }) { 173 + throw OAuthManagerError.tokenExchangeFailed 174 + } 175 + 176 + guard let state = queryItems.first(where: { $0.name == "state" })?.value, state == pending.state else { 177 + throw OAuthManagerError.callbackStateMismatch 178 + } 179 + 180 + guard let code = queryItems.first(where: { $0.name == "code" })?.value else { 181 + throw OAuthManagerError.tokenExchangeFailed 182 + } 183 + 184 + let tokenResponse = try await exchangeAuthorizationCode( 185 + code: code, 186 + pending: pending 187 + ) 188 + 189 + guard let subject = tokenResponse.subject, subject == pending.did else { 190 + throw OAuthManagerError.identityResolutionFailed 191 + } 192 + 193 + guard let refreshToken = tokenResponse.refreshToken, refreshToken.isEmpty == false else { 194 + throw OAuthManagerError.tokenExchangeFailed 195 + } 196 + 197 + let session = OAuthSession( 198 + did: pending.did, 199 + pdsURL: pending.pdsURL, 200 + authorizationServer: pending.authorizationServerMetadata.issuer, 201 + tokenEndpoint: pending.authorizationServerMetadata.tokenEndpoint, 202 + accessToken: tokenResponse.accessToken, 203 + refreshToken: refreshToken, 204 + tokenType: tokenResponse.tokenType, 205 + scope: tokenResponse.scope, 206 + expiresIn: tokenResponse.expiresIn, 207 + issuedAt: Date() 208 + ) 209 + try await store(session: session) 210 + resourceServerNonce = nil 211 + return session 212 + } 213 + 214 + public func refreshSession(force: Bool = false) async throws -> OAuthSession { 215 + guard let session = cachedSession else { throw OAuthManagerError.refreshFailed } 216 + if !force, session.needsRefresh() == false { 217 + return session 218 + } 219 + 220 + let refreshed = try await performRefresh(session: session) 221 + try await store(session: refreshed) 222 + return refreshed 223 + } 224 + 225 + public func signOut() async throws { 226 + cachedSession = nil 227 + pendingAuthorization = nil 228 + authorizationServerNonce = nil 229 + resourceServerNonce = nil 230 + cachedClientMetadata = nil 231 + try await credentialStore.deleteSession() 232 + APEnvironment.current.accessToken = nil 233 + APEnvironment.current.refreshToken = nil 234 + APEnvironment.current.host = nil 235 + } 236 + 237 + // MARK: - Nonce Management 238 + 239 + public func updateAuthorizationServerNonce(_ nonce: String?) async { 240 + authorizationServerNonce = nonce 241 + } 242 + 243 + public func updateResourceServerNonce(_ nonce: String?) async { 244 + resourceServerNonce = nonce 245 + } 246 + 247 + public func currentResourceServerNonce() -> String? { 248 + resourceServerNonce 249 + } 250 + 251 + public func currentAuthorizationServerNonce() -> String? { 252 + authorizationServerNonce 253 + } 254 + 255 + // MARK: - Private helpers 256 + 257 + private func loadClientMetadata() async throws -> OAuthClientMetadata { 258 + if let metadata = cachedClientMetadata { 259 + return metadata 260 + } 261 + 262 + var request = URLRequest(url: configuration.clientMetadataURL) 263 + request.setValue("application/json", forHTTPHeaderField: "Accept") 264 + let (data, response) = try await httpClient.send(request) 265 + guard (200..<300).contains(response.statusCode) else { 266 + throw OAuthManagerError.clientMetadataValidationFailed 267 + } 268 + let metadata = try httpClient.decodeJSON(OAuthClientMetadata.self, from: data) 269 + try validateClientMetadata(metadata) 270 + cachedClientMetadata = metadata 271 + return metadata 272 + } 273 + 274 + private func validateClientMetadata(_ metadata: OAuthClientMetadata) throws { 275 + guard metadata.clientID == configuration.clientMetadataURL else { 276 + throw OAuthManagerError.clientMetadataValidationFailed 277 + } 278 + guard metadata.redirectURIs.contains(configuration.redirectURI) else { 279 + throw OAuthManagerError.clientMetadataValidationFailed 280 + } 281 + guard metadata.grantTypes.contains("authorization_code") else { 282 + throw OAuthManagerError.clientMetadataValidationFailed 283 + } 284 + guard metadata.responseTypes.contains("code") else { 285 + throw OAuthManagerError.clientMetadataValidationFailed 286 + } 287 + guard metadata.dPoPBoundAccessTokens else { 288 + throw OAuthManagerError.clientMetadataValidationFailed 289 + } 290 + } 291 + 292 + private func validateAuthorizationServerMetadata(_ metadata: OAuthAuthorizationServerMetadata) throws { 293 + guard metadata.codeChallengeMethodsSupported.contains(where: { $0.caseInsensitiveCompare("S256") == .orderedSame }) else { 294 + throw OAuthManagerError.unsupportedAuthorizationServer 295 + } 296 + guard metadata.dPoPSigningAlgValuesSupported.contains(where: { $0.caseInsensitiveCompare("ES256") == .orderedSame }) else { 297 + throw OAuthManagerError.unsupportedAuthorizationServer 298 + } 299 + guard metadata.scopesSupported.isEmpty || metadata.scopesSupported.contains("atproto") else { 300 + throw OAuthManagerError.unsupportedAuthorizationServer 301 + } 302 + } 303 + 304 + private func generateState() throws -> String { 305 + let data = try randomGenerator.data(count: 32) 306 + return Base64URL.encode(data) 307 + } 308 + 309 + private func performPushedAuthorizationRequest( 310 + metadata: OAuthAuthorizationServerMetadata, 311 + clientMetadata: OAuthClientMetadata, 312 + handle: String, 313 + did: String, 314 + pkce: PKCEValues, 315 + state: String 316 + ) async throws -> (requestURI: String, nonce: String?) { 317 + let parameters: [String: String] = { 318 + var base: [String: String] = [ 319 + "client_id": configuration.clientMetadataURL.absoluteString, 320 + "redirect_uri": configuration.redirectURI.absoluteString, 321 + "response_type": "code", 322 + "scope": configuration.requestedScopes.joined(separator: " "), 323 + "code_challenge": pkce.challenge, 324 + "code_challenge_method": "S256", 325 + "state": state, 326 + "login_hint": handle, 327 + "resource": did 328 + ] 329 + configuration.additionalAuthorizationParameters.forEach { base[$0.key] = $0.value } 330 + return base 331 + }() 332 + 333 + var request = URLRequest(url: metadata.pushedAuthorizationRequestEndpoint) 334 + request.httpMethod = "POST" 335 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 336 + request.httpBody = try formEncodedBody(from: parameters) 337 + 338 + var currentNonce = authorizationServerNonce 339 + for _ in 0..<2 { 340 + let proof = try dpopGenerator.generateProof( 341 + method: "POST", 342 + url: metadata.pushedAuthorizationRequestEndpoint, 343 + nonce: currentNonce, 344 + accessToken: nil 345 + ) 346 + request.setValue(proof, forHTTPHeaderField: "DPoP") 347 + let (data, response) = try await httpClient.send(request) 348 + if let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false { 349 + currentNonce = nonce 350 + } 351 + 352 + switch response.statusCode { 353 + case 201: 354 + authorizationServerNonce = currentNonce 355 + let parResponse = try httpClient.decodeJSON(PushedAuthorizationRequestResponse.self, from: data) 356 + return (parResponse.requestURI, currentNonce) 357 + case 400, 401: 358 + if currentNonce != nil { 359 + continue 360 + } 361 + if let errorResponse = try? httpClient.decodeJSON(OAuthErrorResponse.self, from: data), 362 + errorResponse.error == "use_dpop_nonce" { 363 + continue 364 + } 365 + throw OAuthManagerError.tokenExchangeFailed 366 + default: 367 + throw OAuthManagerError.tokenExchangeFailed 368 + } 369 + } 370 + 371 + throw OAuthManagerError.tokenExchangeFailed 372 + } 373 + 374 + private func makeAuthorizationURL(endpoint: URL, clientID: URL, requestURI: String) -> URL { 375 + var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) ?? URLComponents() 376 + var items = components.queryItems ?? [] 377 + items.append(URLQueryItem(name: "client_id", value: clientID.absoluteString)) 378 + items.append(URLQueryItem(name: "request_uri", value: requestURI)) 379 + components.queryItems = items 380 + return components.url ?? endpoint 381 + } 382 + 383 + private func exchangeAuthorizationCode(code: String, pending: PendingAuthorization) async throws -> OAuthTokenResponse { 384 + let parameters: [String: String] = [ 385 + "grant_type": "authorization_code", 386 + "code": code, 387 + "redirect_uri": configuration.redirectURI.absoluteString, 388 + "client_id": configuration.clientMetadataURL.absoluteString, 389 + "code_verifier": pending.pkce.verifier 390 + ] 391 + 392 + var request = URLRequest(url: pending.authorizationServerMetadata.tokenEndpoint) 393 + request.httpMethod = "POST" 394 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 395 + request.httpBody = try formEncodedBody(from: parameters) 396 + 397 + let response = try await sendTokenRequest(request: request, tokenEndpoint: pending.authorizationServerMetadata.tokenEndpoint) 398 + return response 399 + } 400 + 401 + private func performRefresh(session: OAuthSession) async throws -> OAuthSession { 402 + let parameters: [String: String] = [ 403 + "grant_type": "refresh_token", 404 + "refresh_token": session.refreshToken, 405 + "client_id": configuration.clientMetadataURL.absoluteString, 406 + "redirect_uri": configuration.redirectURI.absoluteString 407 + ] 408 + 409 + var request = URLRequest(url: session.tokenEndpoint) 410 + request.httpMethod = "POST" 411 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 412 + request.httpBody = try formEncodedBody(from: parameters) 413 + 414 + let tokenResponse = try await sendTokenRequest(request: request, tokenEndpoint: session.tokenEndpoint) 415 + guard let subject = tokenResponse.subject, subject == session.did else { 416 + throw OAuthManagerError.refreshFailed 417 + } 418 + 419 + let refreshToken: String 420 + if let newToken = tokenResponse.refreshToken, newToken.isEmpty == false { 421 + refreshToken = newToken 422 + } else { 423 + refreshToken = session.refreshToken 424 + } 425 + 426 + return OAuthSession( 427 + did: session.did, 428 + pdsURL: session.pdsURL, 429 + authorizationServer: session.authorizationServer, 430 + tokenEndpoint: session.tokenEndpoint, 431 + accessToken: tokenResponse.accessToken, 432 + refreshToken: refreshToken, 433 + tokenType: tokenResponse.tokenType, 434 + scope: tokenResponse.scope, 435 + expiresIn: tokenResponse.expiresIn, 436 + issuedAt: Date() 437 + ) 438 + } 439 + 440 + private func sendTokenRequest(request: URLRequest, tokenEndpoint: URL) async throws -> OAuthTokenResponse { 441 + var request = request 442 + let nonce = authorizationServerNonce 443 + let proof = try dpopGenerator.generateProof(method: request.httpMethod ?? "POST", url: tokenEndpoint, nonce: nonce, accessToken: nil) 444 + request.setValue(proof, forHTTPHeaderField: "DPoP") 445 + 446 + let (data, response) = try await httpClient.send(request) 447 + if let newNonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), newNonce.isEmpty == false { 448 + authorizationServerNonce = newNonce 449 + } 450 + 451 + switch response.statusCode { 452 + case 200: 453 + return try httpClient.decodeJSON(OAuthTokenResponse.self, from: data) 454 + case 400, 401: 455 + if let errorResponse = try? httpClient.decodeJSON(OAuthErrorResponse.self, from: data), 456 + errorResponse.error == "use_dpop_nonce", 457 + let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false { 458 + authorizationServerNonce = nonce 459 + return try await retryTokenRequest(originalRequest: request, tokenEndpoint: tokenEndpoint) 460 + } 461 + fallthrough 462 + default: 463 + throw OAuthManagerError.tokenExchangeFailed 464 + } 465 + } 466 + 467 + private func retryTokenRequest(originalRequest: URLRequest, tokenEndpoint: URL) async throws -> OAuthTokenResponse { 468 + var request = originalRequest 469 + let proof = try dpopGenerator.generateProof(method: request.httpMethod ?? "POST", url: tokenEndpoint, nonce: authorizationServerNonce, accessToken: nil) 470 + request.setValue(proof, forHTTPHeaderField: "DPoP") 471 + let (data, response) = try await httpClient.send(request) 472 + if let newNonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), newNonce.isEmpty == false { 473 + authorizationServerNonce = newNonce 474 + } 475 + guard response.statusCode == 200 else { throw OAuthManagerError.tokenExchangeFailed } 476 + return try httpClient.decodeJSON(OAuthTokenResponse.self, from: data) 477 + } 478 + 479 + private func formEncodedBody(from parameters: [String: String]) throws -> Data { 480 + var components = URLComponents() 481 + components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } 482 + guard let query = components.percentEncodedQuery, let data = query.data(using: .utf8) else { 483 + throw OAuthManagerError.tokenExchangeFailed 484 + } 485 + return data 486 + } 487 + 488 + private func store(session: OAuthSession) async throws { 489 + cachedSession = session 490 + try await credentialStore.save(session: session) 491 + APEnvironment.current.accessToken = session.accessToken 492 + APEnvironment.current.refreshToken = session.refreshToken 493 + APEnvironment.current.host = session.pdsURL.absoluteString 494 + } 495 + } 496 + 497 + private struct PendingAuthorization: Sendable { 498 + let handle: String 499 + let did: String 500 + let pdsURL: URL 501 + let authorizationServerMetadata: OAuthAuthorizationServerMetadata 502 + let clientMetadata: OAuthClientMetadata 503 + let state: String 504 + let pkce: PKCEValues 505 + let requestURI: String 506 + let issuedAt: Date 507 + }
+5
Sources/CoreATProtocol/OAuth/OAuthUIProvider.swift
··· 1 + import Foundation 2 + 3 + public protocol OAuthUIProvider: Sendable { 4 + func presentAuthorization(at url: URL, callbackScheme: String) async throws -> URL 5 + }
+101
Sources/CoreATProtocol/OAuth/Security/DPoPGenerator.swift
··· 1 + import CryptoKit 2 + import Foundation 3 + 4 + enum DPoPGeneratorError: Error, Sendable { 5 + case invalidURL 6 + case keyUnavailable 7 + } 8 + 9 + @APActor 10 + public final class DPoPGenerator: Sendable { 11 + private var keyPair: DPoPKeyPair 12 + private let clock: () -> Date 13 + 14 + init(keyPair: DPoPKeyPair, clock: @escaping () -> Date = Date.init) { 15 + self.keyPair = keyPair 16 + self.clock = clock 17 + } 18 + 19 + public convenience init(clock: @escaping () -> Date = Date.init) { 20 + self.init(keyPair: DPoPKeyPair(), clock: clock) 21 + } 22 + 23 + public func updateKey(using rawRepresentation: Data) throws { 24 + self.keyPair = try DPoPKeyPair(rawRepresentation: rawRepresentation) 25 + } 26 + 27 + public func exportKey() -> Data { 28 + keyPair.export() 29 + } 30 + 31 + public func generateProof( 32 + method: String, 33 + url: URL, 34 + nonce: String?, 35 + accessToken: String? 36 + ) throws -> String { 37 + let normalizedHTU = try normalize(url: url) 38 + let issuedAt = Int(clock().timeIntervalSince1970) 39 + let header = Header(jwk: keyPair.publicKeyJWK) 40 + let payload = Payload( 41 + htm: method.uppercased(), 42 + htu: normalizedHTU, 43 + iat: issuedAt, 44 + exp: issuedAt + 120, 45 + jti: UUID().uuidString, 46 + nonce: nonce, 47 + ath: accessToken.flatMap { accessTokenHash(for: $0) } 48 + ) 49 + 50 + let encoder = JSONEncoder() 51 + encoder.outputFormatting = [.withoutEscapingSlashes] 52 + let headerEncoded = Base64URL.encode(try encoder.encode(header)) 53 + let payloadEncoded = Base64URL.encode(try encoder.encode(payload)) 54 + let signingInput = Data("\(headerEncoded).\(payloadEncoded)".utf8) 55 + let signature = try keyPair.privateKey.signature(for: signingInput) 56 + let signatureEncoded = Base64URL.encode(signature.derRepresentation) 57 + return "\(headerEncoded).\(payloadEncoded).\(signatureEncoded)" 58 + } 59 + 60 + private func normalize(url: URL) throws -> String { 61 + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { 62 + throw DPoPGeneratorError.invalidURL 63 + } 64 + components.fragment = nil 65 + guard let normalized = components.url?.absoluteString else { 66 + throw DPoPGeneratorError.invalidURL 67 + } 68 + return normalized 69 + } 70 + 71 + private func accessTokenHash(for token: String) -> String { 72 + let digest = SHA256.hash(data: Data(token.utf8)) 73 + return Base64URL.encode(Data(digest)) 74 + } 75 + 76 + private struct Header: Encodable { 77 + let typ = "dpop+jwt" 78 + let alg = "ES256" 79 + let jwk: [String: String] 80 + } 81 + 82 + private struct Payload: Encodable { 83 + let htm: String 84 + let htu: String 85 + let iat: Int 86 + let exp: Int 87 + let jti: String 88 + let nonce: String? 89 + let ath: String? 90 + 91 + private enum CodingKeys: String, CodingKey { 92 + case htm 93 + case htu 94 + case iat 95 + case exp 96 + case jti 97 + case nonce 98 + case ath 99 + } 100 + } 101 + }
+38
Sources/CoreATProtocol/OAuth/Security/DPoPKeyPair.swift
··· 1 + import CryptoKit 2 + import Foundation 3 + 4 + struct DPoPKeyPair: Sendable { 5 + let privateKey: P256.Signing.PrivateKey 6 + 7 + init() { 8 + self.privateKey = P256.Signing.PrivateKey() 9 + } 10 + 11 + init(privateKey: P256.Signing.PrivateKey) { 12 + self.privateKey = privateKey 13 + } 14 + 15 + init(rawRepresentation: Data) throws { 16 + self.privateKey = try P256.Signing.PrivateKey(rawRepresentation: rawRepresentation) 17 + } 18 + 19 + var publicKeyJWK: [String: String] { 20 + let publicKeyData = privateKey.publicKey.x963Representation 21 + // Strip leading 0x04 per SEC1 encoding to expose affine coordinates 22 + let xData = Data(publicKeyData[1..<33]) 23 + let yData = Data(publicKeyData[33..<65]) 24 + 25 + return [ 26 + "kty": "EC", 27 + "crv": "P-256", 28 + "alg": "ES256", 29 + "use": "sig", 30 + "x": Base64URL.encode(xData), 31 + "y": Base64URL.encode(yData) 32 + ] 33 + } 34 + 35 + func export() -> Data { 36 + privateKey.rawRepresentation 37 + } 38 + }
+38
Sources/CoreATProtocol/OAuth/Security/PKCEGenerator.swift
··· 1 + import CryptoKit 2 + import Foundation 3 + 4 + struct PKCEValues: Sendable { 5 + let verifier: String 6 + let challenge: String 7 + } 8 + 9 + struct PKCEGenerator: Sendable { 10 + private let randomGenerator: RandomDataGenerating 11 + 12 + init(randomGenerator: RandomDataGenerating = SecureRandomDataGenerator()) { 13 + self.randomGenerator = randomGenerator 14 + } 15 + 16 + func makeValues() throws -> PKCEValues { 17 + let verifier = try makeVerifier() 18 + let challenge = makeChallenge(from: verifier) 19 + return PKCEValues(verifier: verifier, challenge: challenge) 20 + } 21 + 22 + func makeVerifier() throws -> String { 23 + let candidateLengths = [32, 48, 64] 24 + for length in candidateLengths { 25 + let data = try randomGenerator.data(count: length) 26 + let candidate = Base64URL.encode(data) 27 + if (43...128).contains(candidate.count) { 28 + return candidate 29 + } 30 + } 31 + throw RandomDataGeneratorError.allocationFailed 32 + } 33 + 34 + func makeChallenge(from verifier: String) -> String { 35 + let digest = SHA256.hash(data: Data(verifier.utf8)) 36 + return Base64URL.encode(Data(digest)) 37 + } 38 + }
+23
Sources/CoreATProtocol/OAuth/Security/RandomDataGenerator.swift
··· 1 + import Foundation 2 + import Security 3 + 4 + enum RandomDataGeneratorError: Error, Sendable { 5 + case allocationFailed 6 + case generationFailed(status: OSStatus) 7 + } 8 + 9 + protocol RandomDataGenerating: Sendable { 10 + func data(count: Int) throws -> Data 11 + } 12 + 13 + struct SecureRandomDataGenerator: RandomDataGenerating { 14 + func data(count: Int) throws -> Data { 15 + guard count > 0 else { return Data() } 16 + var buffer = Data(count: count) 17 + let status = buffer.withUnsafeMutableBytes { pointer in 18 + SecRandomCopyBytes(kSecRandomDefault, count, pointer.baseAddress!) 19 + } 20 + guard status == errSecSuccess else { throw RandomDataGeneratorError.generationFailed(status: status) } 21 + return buffer 22 + } 23 + }
+41
Sources/CoreATProtocol/OAuth/Storage/OAuthCredentialStore.swift
··· 1 + import Foundation 2 + 3 + public protocol OAuthCredentialStore: Sendable { 4 + func loadSession() async throws -> OAuthSession? 5 + func save(session: OAuthSession) async throws 6 + func deleteSession() async throws 7 + func loadDPoPKey() async throws -> Data? 8 + func saveDPoPKey(_ data: Data) async throws 9 + func deleteDPoPKey() async throws 10 + } 11 + 12 + public actor InMemoryOAuthCredentialStore: OAuthCredentialStore { 13 + private var session: OAuthSession? 14 + private var dpopKey: Data? 15 + 16 + public init() {} 17 + 18 + public func loadSession() async throws -> OAuthSession? { 19 + session 20 + } 21 + 22 + public func save(session: OAuthSession) async throws { 23 + self.session = session 24 + } 25 + 26 + public func deleteSession() async throws { 27 + session = nil 28 + } 29 + 30 + public func loadDPoPKey() async throws -> Data? { 31 + dpopKey 32 + } 33 + 34 + public func saveDPoPKey(_ data: Data) async throws { 35 + dpopKey = data 36 + } 37 + 38 + public func deleteDPoPKey() async throws { 39 + dpopKey = nil 40 + } 41 + }
+26
Sources/CoreATProtocol/OAuth/Utilities/Base64URL.swift
··· 1 + import Foundation 2 + 3 + enum Base64URLError: Error, Sendable { 4 + case invalidLength 5 + case invalidCharacters 6 + } 7 + 8 + struct Base64URL: Sendable { 9 + static func encode(_ data: Data) -> String { 10 + data.base64EncodedString() 11 + .replacingOccurrences(of: "+", with: "-") 12 + .replacingOccurrences(of: "/", with: "_") 13 + .replacingOccurrences(of: "=", with: "") 14 + } 15 + 16 + static func decode(_ string: String) throws -> Data { 17 + let remainder = string.count % 4 18 + guard remainder != 1 else { throw Base64URLError.invalidLength } 19 + let paddingLength = remainder == 0 ? 0 : 4 - remainder 20 + let padded = string + String(repeating: "=", count: paddingLength) 21 + guard let data = Data(base64Encoded: padded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")) else { 22 + throw Base64URLError.invalidCharacters 23 + } 24 + return data 25 + } 26 + }
+97
Tests/CoreATProtocolTests/OAuthSecurityTests.swift
··· 1 + import CryptoKit 2 + import Foundation 3 + import Testing 4 + @testable import CoreATProtocol 5 + 6 + private struct DeterministicRandomGenerator: RandomDataGenerating { 7 + func data(count: Int) throws -> Data { 8 + Data(repeating: 0x42, count: count) 9 + } 10 + } 11 + 12 + @Test("Base64URL encodes without padding and decodes back") 13 + func base64URLRoundTrip() throws { 14 + let data = Data([0xde, 0xad, 0xbe, 0xef]) 15 + let encoded = Base64URL.encode(data) 16 + #expect(encoded.contains("=") == false) 17 + let decoded = try Base64URL.decode(encoded) 18 + #expect(decoded == data) 19 + } 20 + 21 + @Test("PKCE generator creates verifier within bounds and matching challenge") 22 + func pkceGeneratorProducesExpectedValues() throws { 23 + let generator = PKCEGenerator(randomGenerator: DeterministicRandomGenerator()) 24 + let values = try generator.makeValues() 25 + #expect(values.verifier.count >= 43) 26 + #expect(values.verifier.count <= 128) 27 + 28 + let expectedDigest = SHA256.hash(data: Data(values.verifier.utf8)) 29 + let expectedChallenge = Base64URL.encode(Data(expectedDigest)) 30 + #expect(values.challenge == expectedChallenge) 31 + } 32 + 33 + @Test("DPoP generator signs payload with expected claims") 34 + func dpopGeneratorProducesValidProof() async throws { 35 + let keyPair = DPoPKeyPair() 36 + let generator = await DPoPGenerator(clock: { Date(timeIntervalSince1970: 1_700_000_000) }) 37 + try await generator.updateKey(using: keyPair.export()) 38 + let url = URL(string: "https://example.com/resource")! 39 + let proof = try await generator.generateProof( 40 + method: "GET", 41 + url: url, 42 + nonce: "nonce-value", 43 + accessToken: "access-token" 44 + ) 45 + 46 + let components = proof.split(separator: ".") 47 + #expect(components.count == 3) 48 + 49 + let headerData = try Base64URL.decode(String(components[0])) 50 + let payloadData = try Base64URL.decode(String(components[1])) 51 + let signatureData = try Base64URL.decode(String(components[2])) 52 + 53 + let header = try JSONSerialization.jsonObject(with: headerData) as? [String: Any] 54 + let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any] 55 + 56 + #expect(header?["typ"] as? String == "dpop+jwt") 57 + #expect(header?["alg"] as? String == "ES256") 58 + let jwk = header?["jwk"] as? [String: String] 59 + #expect(jwk?["kty"] == "EC") 60 + #expect(jwk?["crv"] == "P-256") 61 + 62 + #expect(payload?["htm"] as? String == "GET") 63 + #expect(payload?["htu"] as? String == "https://example.com/resource") 64 + #expect(payload?["nonce"] as? String == "nonce-value") 65 + #expect(payload?["ath"] as? String == Base64URL.encode(Data(SHA256.hash(data: Data("access-token".utf8))))) 66 + 67 + if let iat = payload?["iat"] as? Int { 68 + #expect(iat == 1_700_000_000) 69 + } else { 70 + Issue.record("DPoP payload missing iat") 71 + } 72 + 73 + let signingInput = Data((components[0] + "." + components[1]).utf8) 74 + let signature = try P256.Signing.ECDSASignature(derRepresentation: signatureData) 75 + #expect(keyPair.privateKey.publicKey.isValidSignature(signature, for: signingInput)) 76 + } 77 + 78 + @Test("OAuth session refresh heuristics") 79 + func oauthSessionRefreshLogic() { 80 + let issuedAt = Date() 81 + let session = OAuthSession( 82 + did: "did:plc:example", 83 + pdsURL: URL(string: "https://pds.example.com")!, 84 + authorizationServer: URL(string: "https://auth.example.com")!, 85 + tokenEndpoint: URL(string: "https://auth.example.com/token")!, 86 + accessToken: "token", 87 + refreshToken: "refresh", 88 + tokenType: "DPoP", 89 + scope: "atproto", 90 + expiresIn: 3600, 91 + issuedAt: issuedAt 92 + ) 93 + 94 + #expect(session.isExpired(relativeTo: issuedAt.addingTimeInterval(3500)) == false) 95 + #expect(session.needsRefresh(relativeTo: issuedAt.addingTimeInterval(3300), threshold: 400)) 96 + #expect(session.isExpired(relativeTo: issuedAt.addingTimeInterval(3600))) 97 + }