diff --git a/Sources/CoreATProtocol/APEnvironment.swift b/Sources/CoreATProtocol/APEnvironment.swift index d5acbed..ddd292f 100644 --- a/Sources/CoreATProtocol/APEnvironment.swift +++ b/Sources/CoreATProtocol/APEnvironment.swift @@ -14,6 +14,11 @@ public class APEnvironment { public var refreshToken: String? public var atProtocoldelegate: CoreATProtocolDelegate? public let routerDelegate = APRouterDelegate() + public var oauthManager: OAuthManager? { + didSet { + routerDelegate.oauthManager = oauthManager + } + } private init() {} @@ -23,4 +28,3 @@ public class APEnvironment { // self.userAgent = userAgent // } } - diff --git a/Sources/CoreATProtocol/CoreATProtocol.swift b/Sources/CoreATProtocol/CoreATProtocol.swift index 9f21348..2e2914e 100644 --- a/Sources/CoreATProtocol/CoreATProtocol.swift +++ b/Sources/CoreATProtocol/CoreATProtocol.swift @@ -26,3 +26,42 @@ public func updateTokens(access: String?, refresh: String?) { public func update(hostURL: String?) { APEnvironment.current.host = hostURL } + +@APActor +public func configureOAuth( + configuration: OAuthConfiguration, + credentialStore: OAuthCredentialStore? = nil +) async throws { + let store = credentialStore ?? InMemoryOAuthCredentialStore() + let manager = try await OAuthManager(configuration: configuration, credentialStore: store) + APEnvironment.current.oauthManager = manager +} + +@APActor +public func authenticate(handle: String, using uiProvider: OAuthUIProvider) async throws -> OAuthSession { + guard let manager = APEnvironment.current.oauthManager else { + throw OAuthManagerError.invalidAuthorizationState + } + let session = try await manager.authenticate(handle: handle, using: uiProvider) + APEnvironment.current.host = session.pdsURL.absoluteString + return session +} + +@APActor +public func currentOAuthSession() -> OAuthSession? { + APEnvironment.current.oauthManager?.currentSession +} + +@APActor +public func refreshOAuthSession() async throws -> OAuthSession { + guard let manager = APEnvironment.current.oauthManager else { + throw OAuthManagerError.invalidAuthorizationState + } + return try await manager.refreshSession() +} + +@APActor +public func signOutOAuth() async throws { + guard let manager = APEnvironment.current.oauthManager else { return } + try await manager.signOut() +} diff --git a/Sources/CoreATProtocol/Networking.swift b/Sources/CoreATProtocol/Networking.swift index a9c0fb4..7e6acd3 100644 --- a/Sources/CoreATProtocol/Networking.swift +++ b/Sources/CoreATProtocol/Networking.swift @@ -31,41 +31,110 @@ func shouldPerformRequest(lastFetched: Double, timeLimit: Int = 3600) -> Bool { } @APActor -public class APRouterDelegate: NetworkRouterDelegate { - private var shouldRefreshToken = false - +public final class APRouterDelegate: NetworkRouterDelegate { + public var oauthManager: OAuthManager? { + didSet { pendingRetryAction = .none } + } + + private enum RetryAction { + case none + case refreshToken + case regenerateDPoP + } + + private var pendingRetryAction: RetryAction = .none + public func intercept(_ request: inout URLRequest) async { - if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken { - shouldRefreshToken = false - request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization") - } else if let accessToken = APEnvironment.current.accessToken { + if let manager = oauthManager { + do { + try await manager.authenticateResourceRequest(&request) + return + } catch { + // Fall back to legacy bearer injection if OAuth authentication fails. + } + } + + if let accessToken = APEnvironment.current.accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } } - + public func shouldRetry(error: Error, attempts: Int) async throws -> Bool { - func getNewToken() async throws -> Bool { -// shouldRefreshToken = true -// let newSession = try await AtProtoLexicons().refresh(attempts: attempts + 1) -// APEnvironment.current.accessToken = newSession.accessJwt -// APEnvironment.current.refreshToken = newSession.refreshJwt -// await delegate?.sessionUpdated(newSession) -// -// return true - false + if let manager = oauthManager { + switch pendingRetryAction { + case .regenerateDPoP where attempts < 3: + pendingRetryAction = .none + return true + case .refreshToken: + pendingRetryAction = .none + do { + _ = try await manager.refreshSession(force: true) + return true + } catch { + return false + } + default: + pendingRetryAction = .none + } } - - // TODO: verify this works! - if case .network(let networkError) = error as? AtError, - case .statusCode(let statusCode, _) = networkError, - let statusCode = statusCode?.rawValue, (400..<500).contains(statusCode), - attempts == 1 { - return try await getNewToken() - } else if case .message(let message) = error as? AtError, - message.error == AtErrorType.expiredToken.rawValue { - return try await getNewToken() + + if case .message(let message) = error as? AtError, + message.error == AtErrorType.expiredToken.rawValue { + return false + } + + return false + } + + public func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async { + guard let manager = oauthManager else { return } + + if let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false { + await manager.updateResourceServerNonce(nonce) + } + + guard (400..<500).contains(response.statusCode) else { + pendingRetryAction = .none + return + } + + if containsUseDPoPNonce(response: response, data: data) { + pendingRetryAction = .regenerateDPoP + return + } + + if containsInvalidToken(response: response, data: data) { + pendingRetryAction = .refreshToken + return + } + + pendingRetryAction = .none + } + + private func containsUseDPoPNonce(response: HTTPURLResponse, data: Data) -> Bool { + if header(response, containsError: "use_dpop_nonce") { + return true + } + if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data), + errorResponse.error == "use_dpop_nonce" { + return true } - return false } + + private func containsInvalidToken(response: HTTPURLResponse, data: Data) -> Bool { + if header(response, containsError: "invalid_token") { + return true + } + if let errorResponse = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data), + errorResponse.error == "invalid_token" { + return true + } + return false + } + + private func header(_ response: HTTPURLResponse, containsError token: String) -> Bool { + guard let header = response.value(forHTTPHeaderField: "WWW-Authenticate") else { return false } + return header.range(of: "error=\"\(token)\"", options: .caseInsensitive) != nil || header.range(of: "error=\(token)", options: .caseInsensitive) != nil + } } diff --git a/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift b/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift index 366ea0f..ef48a8e 100644 --- a/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift +++ b/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift @@ -4,6 +4,11 @@ import Foundation public protocol NetworkRouterDelegate: AnyObject { func intercept(_ request: inout URLRequest) async func shouldRetry(error: Error, attempts: Int) async throws -> Bool + func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async +} + +extension NetworkRouterDelegate { + public func didReceive(response: HTTPURLResponse, data: Data, for request: URLRequest) async {} } /// Describes the implementation details of a NetworkRouter @@ -63,6 +68,7 @@ public class NetworkRouter: NetworkRouterProtocol { let (data, response) = try await networking.data(for: request, delegate: urlSessionTaskDelegate) guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.noStatusCode } + await delegate?.didReceive(response: httpResponse, data: data, for: request) switch httpResponse.statusCode { case 200...299: return try decoder.decode(T.self, from: data) diff --git a/Sources/CoreATProtocol/OAuth/Identity/DNSResolver.swift b/Sources/CoreATProtocol/OAuth/Identity/DNSResolver.swift new file mode 100644 index 0000000..5ace196 --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/Identity/DNSResolver.swift @@ -0,0 +1,54 @@ +import Foundation + +enum DNSResolverError: Error, Sendable { + case invalidResponse +} + +protocol DNSResolving: Sendable { + func txtRecords(for host: String) async throws -> [String] +} + +@APActor +final class DoHDNSResolver: DNSResolving { + private let baseURL: URL + private let httpClient: OAuthHTTPClient + + init(baseURL: URL = URL(string: "https://cloudflare-dns.com/dns-query")!, httpClient: OAuthHTTPClient = OAuthHTTPClient()) { + self.baseURL = baseURL + self.httpClient = httpClient + } + + func txtRecords(for host: String) async throws -> [String] { + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + throw DNSResolverError.invalidResponse + } + var queryItems = components.queryItems ?? [] + queryItems.append(URLQueryItem(name: "name", value: host)) + queryItems.append(URLQueryItem(name: "type", value: "TXT")) + components.queryItems = queryItems + guard let url = components.url else { throw DNSResolverError.invalidResponse } + var request = URLRequest(url: url) + request.setValue("application/dns-json", forHTTPHeaderField: "Accept") + let (data, _) = try await httpClient.send(request) + let response = try httpClient.decodeJSON(DNSResponse.self, from: data) + return response.answers?.compactMap { $0.txtValue } ?? [] + } + + private struct DNSResponse: Decodable { + struct Answer: Decodable { + let data: String + + var txtValue: String? { + guard data.count >= 2 else { return nil } + var trimmed = data + if trimmed.hasPrefix("\"") && trimmed.hasSuffix("\"") { + trimmed.removeFirst() + trimmed.removeLast() + } + return trimmed + } + } + + let answers: [Answer]? + } +} diff --git a/Sources/CoreATProtocol/OAuth/Identity/IdentityResolver.swift b/Sources/CoreATProtocol/OAuth/Identity/IdentityResolver.swift new file mode 100644 index 0000000..272b81c --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/Identity/IdentityResolver.swift @@ -0,0 +1,134 @@ +import Foundation + +enum IdentityResolverError: Error, Sendable { + case unableToResolveHandle + case invalidDID + case unsupportedDIDMethod + case missingPDSService +} + +@APActor +final class IdentityResolver: Sendable { + private let httpClient: OAuthHTTPClient + private let dnsResolver: DNSResolving + + init(httpClient: OAuthHTTPClient = OAuthHTTPClient(), dnsResolver: DNSResolving = DoHDNSResolver()) { + self.httpClient = httpClient + self.dnsResolver = dnsResolver + } + + func resolveHandle(_ handle: String) async throws -> String { + if handle.lowercased().hasPrefix("did:") { + return handle + } + + if let did = try? await resolveViaHTTPS(handle: handle) { + return did + } + + if let did = try? await resolveViaDNS(handle: handle) { + return did + } + + throw IdentityResolverError.unableToResolveHandle + } + + func fetchDIDDocument(for did: String) async throws -> DIDDocument { + if did.hasPrefix("did:plc:") { + let identifier = String(did.dropFirst("did:plc:".count)) + guard let url = URL(string: "https://plc.directory/\(identifier)") else { + throw IdentityResolverError.invalidDID + } + return try await fetchJSON(url: url, type: DIDDocument.self) + } else if did.hasPrefix("did:web:") { + let components = try webDIDComponents(did: did) + return try await fetchJSON(url: components.url, type: DIDDocument.self) + } else { + throw IdentityResolverError.unsupportedDIDMethod + } + } + + func discoverProtectedResource(for pdsURL: URL) async throws -> OAuthProtectedResourceMetadata { + let endpoint = pdsURL.appendingPathComponent(".well-known/oauth-protected-resource") + return try await fetchJSON(url: endpoint, type: OAuthProtectedResourceMetadata.self) + } + + func fetchAuthorizationServerMetadata(from url: URL) async throws -> OAuthAuthorizationServerMetadata { + let endpoint = url.appendingPathComponent(".well-known/oauth-authorization-server") + return try await fetchJSON(url: endpoint, type: OAuthAuthorizationServerMetadata.self) + } + + func extractPDSEndpoint(from document: DIDDocument) throws -> URL { + guard let service = document.service(ofType: "AtprotoPersonalDataServer"), let url = URL(string: service.serviceEndpoint) else { + throw IdentityResolverError.missingPDSService + } + return url + } + + // MARK: - Private + + private func resolveViaHTTPS(handle: String) async throws -> String? { + var components = URLComponents() + components.scheme = "https" + components.host = handle + components.path = "/.well-known/atproto-did" + guard let url = components.url else { return nil } + var request = URLRequest(url: url) + request.timeoutInterval = 5 + let (data, response) = try await httpClient.send(request) + guard (200..<300).contains(response.statusCode) else { return nil } + let did = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + guard did.isEmpty == false, did.lowercased().hasPrefix("did:") else { return nil } + return did + } + + private func resolveViaDNS(handle: String) async throws -> String? { + let hostname = "_atproto.\(handle)" + let records = try await dnsResolver.txtRecords(for: hostname) + for record in records { + let parts = record.split(separator: "=", maxSplits: 1).map(String.init) + if parts.count == 2, parts[0] == "did" { + return parts[1] + } + } + return nil + } + + private func fetchJSON(url: URL, type: T.Type) async throws -> T { + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + let (data, response) = try await httpClient.send(request) + guard (200..<300).contains(response.statusCode) else { + throw IdentityResolverError.invalidDID + } + return try httpClient.decodeJSON(T.self, from: data) + } + + private func webDIDComponents(did: String) throws -> (host: String, pathSegments: [String], url: URL) { + let prefix = "did:web:" + guard did.hasPrefix(prefix) else { throw IdentityResolverError.invalidDID } + let suffix = String(did.dropFirst(prefix.count)) + let segments = suffix.split(separator: ":").map { segment in + segment.removingPercentEncoding ?? String(segment) + } + guard let host = segments.first else { + throw IdentityResolverError.invalidDID + } + let pathSegments = Array(segments.dropFirst()) + var components = URLComponents() + components.scheme = "https" + components.host = host + let path: String + if pathSegments.isEmpty { + path = "/.well-known/did.json" + } else { + let joined = pathSegments.joined(separator: "/") + path = "/\(joined)/did.json" + } + components.path = path + guard let url = components.url else { + throw IdentityResolverError.invalidDID + } + return (host, pathSegments, url) + } +} diff --git a/Sources/CoreATProtocol/OAuth/Models/DIDDocument.swift b/Sources/CoreATProtocol/OAuth/Models/DIDDocument.swift new file mode 100644 index 0000000..75cbeb6 --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/Models/DIDDocument.swift @@ -0,0 +1,47 @@ +import Foundation + +struct DIDDocument: Decodable, Sendable { + struct Service: Decodable, Sendable { + let id: String + let type: String + let serviceEndpoint: String + + private enum CodingKeys: String, CodingKey { + case id + case type + case serviceEndpoint + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.type = try container.decode(String.self, forKey: .type) + if let endpoint = try? container.decode(String.self, forKey: .serviceEndpoint) { + self.serviceEndpoint = endpoint + } else if let endpointObject = try? container.decode(ServiceEndpoint.self, forKey: .serviceEndpoint) { + guard let uri = endpointObject.uri else { + throw DecodingError.dataCorruptedError(forKey: .serviceEndpoint, in: container, debugDescription: "Missing uri field in service endpoint object") + } + self.serviceEndpoint = uri + } else { + throw DecodingError.dataCorruptedError(forKey: .serviceEndpoint, in: container, debugDescription: "Unsupported service endpoint type") + } + } + + private struct ServiceEndpoint: Decodable { + let uri: String? + } + } + + let id: String + let services: [Service] + + private enum CodingKeys: String, CodingKey { + case id + case services = "service" + } + + func service(ofType type: String) -> Service? { + services.first { $0.type.localizedCaseInsensitiveCompare(type) == .orderedSame } + } +} diff --git a/Sources/CoreATProtocol/OAuth/Models/OAuthConfiguration.swift b/Sources/CoreATProtocol/OAuth/Models/OAuthConfiguration.swift new file mode 100644 index 0000000..383a004 --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/Models/OAuthConfiguration.swift @@ -0,0 +1,30 @@ +import Foundation + +public struct OAuthConfiguration: Sendable { + public let clientMetadataURL: URL + public let redirectURI: URL + public let requestedScopes: [String] + public let additionalAuthorizationParameters: [String: String] + + public init( + clientMetadataURL: URL, + redirectURI: URL, + requestedScopes: [String] = ["atproto"], + additionalAuthorizationParameters: [String: String] = [:] + ) { + self.clientMetadataURL = clientMetadataURL + self.redirectURI = redirectURI + var scopes = requestedScopes + if scopes.isEmpty { + scopes = ["atproto"] + } else if scopes.contains("atproto") == false { + scopes.append("atproto") + } + var uniqueScopes: [String] = [] + for scope in scopes where uniqueScopes.contains(scope) == false { + uniqueScopes.append(scope) + } + self.requestedScopes = uniqueScopes + self.additionalAuthorizationParameters = additionalAuthorizationParameters + } +} diff --git a/Sources/CoreATProtocol/OAuth/Models/OAuthMetadata.swift b/Sources/CoreATProtocol/OAuth/Models/OAuthMetadata.swift new file mode 100644 index 0000000..db9e59a --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/Models/OAuthMetadata.swift @@ -0,0 +1,160 @@ +import Foundation + +struct OAuthProtectedResourceMetadata: Decodable, Sendable { + let authorizationServers: [URL] + + private enum CodingKeys: String, CodingKey { + case authorizationServers = "authorization_servers" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let values = try container.decodeIfPresent([String].self, forKey: .authorizationServers) ?? [] + self.authorizationServers = try values.map { value in + guard let url = URL(string: value) else { + throw DecodingError.dataCorruptedError(forKey: .authorizationServers, in: container, debugDescription: "Invalid authorization server URL") + } + return url + } + } +} + +struct OAuthAuthorizationServerMetadata: Decodable, Sendable { + let issuer: URL + let authorizationEndpoint: URL + let tokenEndpoint: URL + let pushedAuthorizationRequestEndpoint: URL + let codeChallengeMethodsSupported: [String] + let dPoPSigningAlgValuesSupported: [String] + let scopesSupported: [String] + + private enum CodingKeys: String, CodingKey { + case issuer + case authorizationEndpoint = "authorization_endpoint" + case tokenEndpoint = "token_endpoint" + case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint" + case codeChallengeMethodsSupported = "code_challenge_methods_supported" + case dPoPSigningAlgValuesSupported = "dpop_signing_alg_values_supported" + case scopesSupported = "scopes_supported" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard let issuer = URL(string: try container.decode(String.self, forKey: .issuer)) else { + throw DecodingError.dataCorruptedError(forKey: .issuer, in: container, debugDescription: "Invalid issuer URL") + } + guard let authorizationEndpoint = URL(string: try container.decode(String.self, forKey: .authorizationEndpoint)) else { + throw DecodingError.dataCorruptedError(forKey: .authorizationEndpoint, in: container, debugDescription: "Invalid authorization endpoint") + } + guard let tokenEndpoint = URL(string: try container.decode(String.self, forKey: .tokenEndpoint)) else { + throw DecodingError.dataCorruptedError(forKey: .tokenEndpoint, in: container, debugDescription: "Invalid token endpoint") + } + guard let parEndpoint = URL(string: try container.decode(String.self, forKey: .pushedAuthorizationRequestEndpoint)) else { + throw DecodingError.dataCorruptedError(forKey: .pushedAuthorizationRequestEndpoint, in: container, debugDescription: "Invalid PAR endpoint") + } + + self.issuer = issuer + self.authorizationEndpoint = authorizationEndpoint + self.tokenEndpoint = tokenEndpoint + self.pushedAuthorizationRequestEndpoint = parEndpoint + self.codeChallengeMethodsSupported = try container.decodeIfPresent([String].self, forKey: .codeChallengeMethodsSupported) ?? [] + self.dPoPSigningAlgValuesSupported = try container.decodeIfPresent([String].self, forKey: .dPoPSigningAlgValuesSupported) ?? [] + self.scopesSupported = try container.decodeIfPresent([String].self, forKey: .scopesSupported) ?? [] + } +} + +struct OAuthClientMetadata: Decodable, Sendable { + let clientID: URL + let scope: String + let redirectURIs: [URL] + let grantTypes: [String] + let responseTypes: [String] + let tokenEndpointAuthMethod: String + let tokenEndpointAuthSigningAlg: String? + let dPoPBoundAccessTokens: Bool + + private enum CodingKeys: String, CodingKey { + case clientID = "client_id" + case scope + case redirectURIs = "redirect_uris" + case grantTypes = "grant_types" + case responseTypes = "response_types" + case tokenEndpointAuthMethod = "token_endpoint_auth_method" + case tokenEndpointAuthSigningAlg = "token_endpoint_auth_signing_alg" + case dPoPBoundAccessTokens = "dpop_bound_access_tokens" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard let clientID = URL(string: try container.decode(String.self, forKey: .clientID)) else { + throw DecodingError.dataCorruptedError(forKey: .clientID, in: container, debugDescription: "Invalid client metadata URL") + } + self.clientID = clientID + self.scope = try container.decode(String.self, forKey: .scope) + let redirectStrings = try container.decode([String].self, forKey: .redirectURIs) + self.redirectURIs = try redirectStrings.map { value in + guard let url = URL(string: value) else { + throw DecodingError.dataCorruptedError(forKey: .redirectURIs, in: container, debugDescription: "Invalid redirect URI") + } + return url + } + self.grantTypes = try container.decode([String].self, forKey: .grantTypes) + self.responseTypes = try container.decode([String].self, forKey: .responseTypes) + self.tokenEndpointAuthMethod = try container.decode(String.self, forKey: .tokenEndpointAuthMethod) + self.tokenEndpointAuthSigningAlg = try container.decodeIfPresent(String.self, forKey: .tokenEndpointAuthSigningAlg) + self.dPoPBoundAccessTokens = try container.decode(Bool.self, forKey: .dPoPBoundAccessTokens) + } +} + +struct OAuthTokenResponse: Decodable, Sendable { + let accessToken: String + let refreshToken: String? + let tokenType: String + let expiresIn: TimeInterval? + let scope: String? + let issuedTokenType: String? + let subject: String? + + private enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case tokenType = "token_type" + case expiresIn = "expires_in" + case scope + case issuedTokenType = "issued_token_type" + case subject = "sub" + } +} + +struct PushedAuthorizationRequestResponse: Decodable, Sendable { + let requestURI: String + let expiresIn: Int + + private enum CodingKeys: String, CodingKey { + case requestURI = "request_uri" + case expiresIn = "expires_in" + } +} + +struct OAuthErrorResponse: Decodable, Error, Sendable { + let error: String + let errorDescription: String? + let errorURI: URL? + + private enum CodingKeys: String, CodingKey { + case error + case errorDescription = "error_description" + case errorURI = "error_uri" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.error = try container.decode(String.self, forKey: .error) + self.errorDescription = try container.decodeIfPresent(String.self, forKey: .errorDescription) + if let raw = try container.decodeIfPresent(String.self, forKey: .errorURI) { + self.errorURI = URL(string: raw) + } else { + self.errorURI = nil + } + } +} diff --git a/Sources/CoreATProtocol/OAuth/Models/OAuthSession.swift b/Sources/CoreATProtocol/OAuth/Models/OAuthSession.swift new file mode 100644 index 0000000..ccc83d2 --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/Models/OAuthSession.swift @@ -0,0 +1,53 @@ +import Foundation + +public struct OAuthSession: Codable, Sendable { + public let did: String + public let pdsURL: URL + public let authorizationServer: URL + public let tokenEndpoint: URL + public let accessToken: String + public let refreshToken: String + public let tokenType: String + public let scope: String? + public let expiresIn: TimeInterval? + public let issuedAt: Date + + public init( + did: String, + pdsURL: URL, + authorizationServer: URL, + tokenEndpoint: URL, + accessToken: String, + refreshToken: String, + tokenType: String, + scope: String?, + expiresIn: TimeInterval?, + issuedAt: Date + ) { + self.did = did + self.pdsURL = pdsURL + self.authorizationServer = authorizationServer + self.tokenEndpoint = tokenEndpoint + self.accessToken = accessToken + self.refreshToken = refreshToken + self.tokenType = tokenType + self.scope = scope + self.expiresIn = expiresIn + self.issuedAt = issuedAt + } + + public func isExpired(relativeTo date: Date = Date(), tolerance: TimeInterval = 0) -> Bool { + guard let expiresAt else { return false } + return expiresAt <= date.addingTimeInterval(tolerance * -1) + } + + public func needsRefresh(relativeTo date: Date = Date(), threshold: TimeInterval = 300) -> Bool { + guard let expiresAt else { return false } + return expiresAt <= date.addingTimeInterval(threshold) + } + + public var expiresAt: Date? { + guard let expiresIn else { return nil } + return issuedAt.addingTimeInterval(expiresIn) + } +} diff --git a/Sources/CoreATProtocol/OAuth/Networking/OAuthHTTPClient.swift b/Sources/CoreATProtocol/OAuth/Networking/OAuthHTTPClient.swift new file mode 100644 index 0000000..aba39a9 --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/Networking/OAuthHTTPClient.swift @@ -0,0 +1,34 @@ +import Foundation + +enum OAuthNetworkingError: Error, Sendable { + case invalidResponse +} + +@APActor +final class OAuthHTTPClient: Sendable { + private let networking: Networking + private let jsonDecoder: JSONDecoder + + init(networking: Networking = URLSession.shared, decoder: JSONDecoder? = nil) { + self.networking = networking + if let decoder { + self.jsonDecoder = decoder + } else { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + self.jsonDecoder = decoder + } + } + + func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await networking.data(for: request, delegate: nil) + guard let httpResponse = response as? HTTPURLResponse else { + throw OAuthNetworkingError.invalidResponse + } + return (data, httpResponse) + } + + func decodeJSON(_ type: T.Type, from data: Data) throws -> T { + try jsonDecoder.decode(T.self, from: data) + } +} diff --git a/Sources/CoreATProtocol/OAuth/OAuthManager.swift b/Sources/CoreATProtocol/OAuth/OAuthManager.swift new file mode 100644 index 0000000..0021f9d --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/OAuthManager.swift @@ -0,0 +1,507 @@ +import Foundation + +public enum OAuthManagerError: Error, Sendable { + case missingAuthorizationServer + case invalidAuthorizationState + case authorizationInProgress + case callbackStateMismatch + case authorizationCancelled + case tokenExchangeFailed + case refreshFailed + case invalidRedirectURL + case unsupportedAuthorizationServer + case clientMetadataValidationFailed + case identityResolutionFailed + case missingSession + case invalidRequest +} + +public struct AuthorizationRequest: Sendable { + public let authorizationURL: URL + public let redirectURI: URL +} + +@APActor +public final class OAuthManager: Sendable { + private let configuration: OAuthConfiguration + private let credentialStore: OAuthCredentialStore + private let identityResolver: IdentityResolver + private let httpClient: OAuthHTTPClient + private let randomGenerator: RandomDataGenerating + private var dpopGenerator: DPoPGenerator + + private var cachedClientMetadata: OAuthClientMetadata? + private var pendingAuthorization: PendingAuthorization? + private var cachedSession: OAuthSession? + private var authorizationServerNonce: String? + private var resourceServerNonce: String? + + init( + configuration: OAuthConfiguration, + credentialStore: OAuthCredentialStore, + identityResolver: IdentityResolver = IdentityResolver(), + httpClient: OAuthHTTPClient = OAuthHTTPClient(), + randomGenerator: RandomDataGenerating = SecureRandomDataGenerator() + ) async throws { + self.configuration = configuration + self.credentialStore = credentialStore + self.identityResolver = identityResolver + self.httpClient = httpClient + self.randomGenerator = randomGenerator + + if let keyData = try await credentialStore.loadDPoPKey(), + (try? DPoPKeyPair(rawRepresentation: keyData)) != nil { + self.dpopGenerator = DPoPGenerator(keyPair: try DPoPKeyPair(rawRepresentation: keyData)) + } else { + let keyPair = DPoPKeyPair() + self.dpopGenerator = DPoPGenerator(keyPair: keyPair) + try await credentialStore.saveDPoPKey(keyPair.export()) + } + + self.cachedSession = try await credentialStore.loadSession() + } + + public convenience init( + configuration: OAuthConfiguration, + credentialStore: OAuthCredentialStore + ) async throws { + try await self.init( + configuration: configuration, + credentialStore: credentialStore, + identityResolver: IdentityResolver(), + httpClient: OAuthHTTPClient(), + randomGenerator: SecureRandomDataGenerator() + ) + } + + public var currentSession: OAuthSession? { + cachedSession + } + + public func authenticateResourceRequest(_ request: inout URLRequest) async throws { + guard let url = request.url else { throw OAuthManagerError.invalidRequest } + guard var session = cachedSession else { throw OAuthManagerError.missingSession } + + if session.needsRefresh() { + session = try await refreshSession(force: true) + } + + let proof = try dpopGenerator.generateProof( + method: request.httpMethod ?? "GET", + url: url, + nonce: resourceServerNonce, + accessToken: session.accessToken + ) + + request.setValue("DPoP \(session.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue(proof, forHTTPHeaderField: "DPoP") + } + + public func authenticate(handle: String, using uiProvider: OAuthUIProvider) async throws -> OAuthSession { + let request = try await beginAuthorization(for: handle) + guard let callbackScheme = configuration.redirectURI.scheme else { + throw OAuthManagerError.invalidRedirectURL + } + let callbackURL = try await uiProvider.presentAuthorization(at: request.authorizationURL, callbackScheme: callbackScheme) + return try await resumeAuthorization(from: callbackURL) + } + + public func beginAuthorization(for handle: String) async throws -> AuthorizationRequest { + guard pendingAuthorization == nil else { throw OAuthManagerError.authorizationInProgress } + + let did = try await identityResolver.resolveHandle(handle) + let didDocument = try await identityResolver.fetchDIDDocument(for: did) + let pdsEndpoint = try identityResolver.extractPDSEndpoint(from: didDocument) + + let protectedMetadata = try await identityResolver.discoverProtectedResource(for: pdsEndpoint) + guard let authorizationServerURL = protectedMetadata.authorizationServers.first else { + throw OAuthManagerError.missingAuthorizationServer + } + let authMetadata = try await identityResolver.fetchAuthorizationServerMetadata(from: authorizationServerURL) + try validateAuthorizationServerMetadata(authMetadata) + + let clientMetadata = try await loadClientMetadata() + + let pkce = try PKCEGenerator(randomGenerator: randomGenerator).makeValues() + let state = try generateState() + + let parResult = try await performPushedAuthorizationRequest( + metadata: authMetadata, + clientMetadata: clientMetadata, + handle: handle, + did: did, + pkce: pkce, + state: state + ) + + authorizationServerNonce = parResult.nonce ?? authorizationServerNonce + + let authorizationURL = makeAuthorizationURL( + endpoint: authMetadata.authorizationEndpoint, + clientID: clientMetadata.clientID, + requestURI: parResult.requestURI + ) + + pendingAuthorization = PendingAuthorization( + handle: handle, + did: did, + pdsURL: pdsEndpoint, + authorizationServerMetadata: authMetadata, + clientMetadata: clientMetadata, + state: state, + pkce: pkce, + requestURI: parResult.requestURI, + issuedAt: Date() + ) + + return AuthorizationRequest(authorizationURL: authorizationURL, redirectURI: configuration.redirectURI) + } + + public func resumeAuthorization(from callbackURL: URL) async throws -> OAuthSession { + guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else { + throw OAuthManagerError.invalidRedirectURL + } + guard let pending = pendingAuthorization else { throw OAuthManagerError.invalidAuthorizationState } + defer { pendingAuthorization = nil } + + if components.scheme != configuration.redirectURI.scheme { + throw OAuthManagerError.invalidRedirectURL + } + + let queryItems = components.queryItems ?? [] + if queryItems.contains(where: { $0.name == "error" }) { + throw OAuthManagerError.tokenExchangeFailed + } + + guard let state = queryItems.first(where: { $0.name == "state" })?.value, state == pending.state else { + throw OAuthManagerError.callbackStateMismatch + } + + guard let code = queryItems.first(where: { $0.name == "code" })?.value else { + throw OAuthManagerError.tokenExchangeFailed + } + + let tokenResponse = try await exchangeAuthorizationCode( + code: code, + pending: pending + ) + + guard let subject = tokenResponse.subject, subject == pending.did else { + throw OAuthManagerError.identityResolutionFailed + } + + guard let refreshToken = tokenResponse.refreshToken, refreshToken.isEmpty == false else { + throw OAuthManagerError.tokenExchangeFailed + } + + let session = OAuthSession( + did: pending.did, + pdsURL: pending.pdsURL, + authorizationServer: pending.authorizationServerMetadata.issuer, + tokenEndpoint: pending.authorizationServerMetadata.tokenEndpoint, + accessToken: tokenResponse.accessToken, + refreshToken: refreshToken, + tokenType: tokenResponse.tokenType, + scope: tokenResponse.scope, + expiresIn: tokenResponse.expiresIn, + issuedAt: Date() + ) + try await store(session: session) + resourceServerNonce = nil + return session + } + + public func refreshSession(force: Bool = false) async throws -> OAuthSession { + guard let session = cachedSession else { throw OAuthManagerError.refreshFailed } + if !force, session.needsRefresh() == false { + return session + } + + let refreshed = try await performRefresh(session: session) + try await store(session: refreshed) + return refreshed + } + + public func signOut() async throws { + cachedSession = nil + pendingAuthorization = nil + authorizationServerNonce = nil + resourceServerNonce = nil + cachedClientMetadata = nil + try await credentialStore.deleteSession() + APEnvironment.current.accessToken = nil + APEnvironment.current.refreshToken = nil + APEnvironment.current.host = nil + } + + // MARK: - Nonce Management + + public func updateAuthorizationServerNonce(_ nonce: String?) async { + authorizationServerNonce = nonce + } + + public func updateResourceServerNonce(_ nonce: String?) async { + resourceServerNonce = nonce + } + + public func currentResourceServerNonce() -> String? { + resourceServerNonce + } + + public func currentAuthorizationServerNonce() -> String? { + authorizationServerNonce + } + + // MARK: - Private helpers + + private func loadClientMetadata() async throws -> OAuthClientMetadata { + if let metadata = cachedClientMetadata { + return metadata + } + + var request = URLRequest(url: configuration.clientMetadataURL) + request.setValue("application/json", forHTTPHeaderField: "Accept") + let (data, response) = try await httpClient.send(request) + guard (200..<300).contains(response.statusCode) else { + throw OAuthManagerError.clientMetadataValidationFailed + } + let metadata = try httpClient.decodeJSON(OAuthClientMetadata.self, from: data) + try validateClientMetadata(metadata) + cachedClientMetadata = metadata + return metadata + } + + private func validateClientMetadata(_ metadata: OAuthClientMetadata) throws { + guard metadata.clientID == configuration.clientMetadataURL else { + throw OAuthManagerError.clientMetadataValidationFailed + } + guard metadata.redirectURIs.contains(configuration.redirectURI) else { + throw OAuthManagerError.clientMetadataValidationFailed + } + guard metadata.grantTypes.contains("authorization_code") else { + throw OAuthManagerError.clientMetadataValidationFailed + } + guard metadata.responseTypes.contains("code") else { + throw OAuthManagerError.clientMetadataValidationFailed + } + guard metadata.dPoPBoundAccessTokens else { + throw OAuthManagerError.clientMetadataValidationFailed + } + } + + private func validateAuthorizationServerMetadata(_ metadata: OAuthAuthorizationServerMetadata) throws { + guard metadata.codeChallengeMethodsSupported.contains(where: { $0.caseInsensitiveCompare("S256") == .orderedSame }) else { + throw OAuthManagerError.unsupportedAuthorizationServer + } + guard metadata.dPoPSigningAlgValuesSupported.contains(where: { $0.caseInsensitiveCompare("ES256") == .orderedSame }) else { + throw OAuthManagerError.unsupportedAuthorizationServer + } + guard metadata.scopesSupported.isEmpty || metadata.scopesSupported.contains("atproto") else { + throw OAuthManagerError.unsupportedAuthorizationServer + } + } + + private func generateState() throws -> String { + let data = try randomGenerator.data(count: 32) + return Base64URL.encode(data) + } + + private func performPushedAuthorizationRequest( + metadata: OAuthAuthorizationServerMetadata, + clientMetadata: OAuthClientMetadata, + handle: String, + did: String, + pkce: PKCEValues, + state: String + ) async throws -> (requestURI: String, nonce: String?) { + let parameters: [String: String] = { + var base: [String: String] = [ + "client_id": configuration.clientMetadataURL.absoluteString, + "redirect_uri": configuration.redirectURI.absoluteString, + "response_type": "code", + "scope": configuration.requestedScopes.joined(separator: " "), + "code_challenge": pkce.challenge, + "code_challenge_method": "S256", + "state": state, + "login_hint": handle, + "resource": did + ] + configuration.additionalAuthorizationParameters.forEach { base[$0.key] = $0.value } + return base + }() + + var request = URLRequest(url: metadata.pushedAuthorizationRequestEndpoint) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = try formEncodedBody(from: parameters) + + var currentNonce = authorizationServerNonce + for _ in 0..<2 { + let proof = try dpopGenerator.generateProof( + method: "POST", + url: metadata.pushedAuthorizationRequestEndpoint, + nonce: currentNonce, + accessToken: nil + ) + request.setValue(proof, forHTTPHeaderField: "DPoP") + let (data, response) = try await httpClient.send(request) + if let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false { + currentNonce = nonce + } + + switch response.statusCode { + case 201: + authorizationServerNonce = currentNonce + let parResponse = try httpClient.decodeJSON(PushedAuthorizationRequestResponse.self, from: data) + return (parResponse.requestURI, currentNonce) + case 400, 401: + if currentNonce != nil { + continue + } + if let errorResponse = try? httpClient.decodeJSON(OAuthErrorResponse.self, from: data), + errorResponse.error == "use_dpop_nonce" { + continue + } + throw OAuthManagerError.tokenExchangeFailed + default: + throw OAuthManagerError.tokenExchangeFailed + } + } + + throw OAuthManagerError.tokenExchangeFailed + } + + private func makeAuthorizationURL(endpoint: URL, clientID: URL, requestURI: String) -> URL { + var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) ?? URLComponents() + var items = components.queryItems ?? [] + items.append(URLQueryItem(name: "client_id", value: clientID.absoluteString)) + items.append(URLQueryItem(name: "request_uri", value: requestURI)) + components.queryItems = items + return components.url ?? endpoint + } + + private func exchangeAuthorizationCode(code: String, pending: PendingAuthorization) async throws -> OAuthTokenResponse { + let parameters: [String: String] = [ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": configuration.redirectURI.absoluteString, + "client_id": configuration.clientMetadataURL.absoluteString, + "code_verifier": pending.pkce.verifier + ] + + var request = URLRequest(url: pending.authorizationServerMetadata.tokenEndpoint) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = try formEncodedBody(from: parameters) + + let response = try await sendTokenRequest(request: request, tokenEndpoint: pending.authorizationServerMetadata.tokenEndpoint) + return response + } + + private func performRefresh(session: OAuthSession) async throws -> OAuthSession { + let parameters: [String: String] = [ + "grant_type": "refresh_token", + "refresh_token": session.refreshToken, + "client_id": configuration.clientMetadataURL.absoluteString, + "redirect_uri": configuration.redirectURI.absoluteString + ] + + var request = URLRequest(url: session.tokenEndpoint) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = try formEncodedBody(from: parameters) + + let tokenResponse = try await sendTokenRequest(request: request, tokenEndpoint: session.tokenEndpoint) + guard let subject = tokenResponse.subject, subject == session.did else { + throw OAuthManagerError.refreshFailed + } + + let refreshToken: String + if let newToken = tokenResponse.refreshToken, newToken.isEmpty == false { + refreshToken = newToken + } else { + refreshToken = session.refreshToken + } + + return OAuthSession( + did: session.did, + pdsURL: session.pdsURL, + authorizationServer: session.authorizationServer, + tokenEndpoint: session.tokenEndpoint, + accessToken: tokenResponse.accessToken, + refreshToken: refreshToken, + tokenType: tokenResponse.tokenType, + scope: tokenResponse.scope, + expiresIn: tokenResponse.expiresIn, + issuedAt: Date() + ) + } + + private func sendTokenRequest(request: URLRequest, tokenEndpoint: URL) async throws -> OAuthTokenResponse { + var request = request + let nonce = authorizationServerNonce + let proof = try dpopGenerator.generateProof(method: request.httpMethod ?? "POST", url: tokenEndpoint, nonce: nonce, accessToken: nil) + request.setValue(proof, forHTTPHeaderField: "DPoP") + + let (data, response) = try await httpClient.send(request) + if let newNonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), newNonce.isEmpty == false { + authorizationServerNonce = newNonce + } + + switch response.statusCode { + case 200: + return try httpClient.decodeJSON(OAuthTokenResponse.self, from: data) + case 400, 401: + if let errorResponse = try? httpClient.decodeJSON(OAuthErrorResponse.self, from: data), + errorResponse.error == "use_dpop_nonce", + let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), nonce.isEmpty == false { + authorizationServerNonce = nonce + return try await retryTokenRequest(originalRequest: request, tokenEndpoint: tokenEndpoint) + } + fallthrough + default: + throw OAuthManagerError.tokenExchangeFailed + } + } + + private func retryTokenRequest(originalRequest: URLRequest, tokenEndpoint: URL) async throws -> OAuthTokenResponse { + var request = originalRequest + let proof = try dpopGenerator.generateProof(method: request.httpMethod ?? "POST", url: tokenEndpoint, nonce: authorizationServerNonce, accessToken: nil) + request.setValue(proof, forHTTPHeaderField: "DPoP") + let (data, response) = try await httpClient.send(request) + if let newNonce = response.value(forHTTPHeaderField: "DPoP-Nonce"), newNonce.isEmpty == false { + authorizationServerNonce = newNonce + } + guard response.statusCode == 200 else { throw OAuthManagerError.tokenExchangeFailed } + return try httpClient.decodeJSON(OAuthTokenResponse.self, from: data) + } + + private func formEncodedBody(from parameters: [String: String]) throws -> Data { + var components = URLComponents() + components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } + guard let query = components.percentEncodedQuery, let data = query.data(using: .utf8) else { + throw OAuthManagerError.tokenExchangeFailed + } + return data + } + + private func store(session: OAuthSession) async throws { + cachedSession = session + try await credentialStore.save(session: session) + APEnvironment.current.accessToken = session.accessToken + APEnvironment.current.refreshToken = session.refreshToken + APEnvironment.current.host = session.pdsURL.absoluteString + } +} + +private struct PendingAuthorization: Sendable { + let handle: String + let did: String + let pdsURL: URL + let authorizationServerMetadata: OAuthAuthorizationServerMetadata + let clientMetadata: OAuthClientMetadata + let state: String + let pkce: PKCEValues + let requestURI: String + let issuedAt: Date +} diff --git a/Sources/CoreATProtocol/OAuth/OAuthUIProvider.swift b/Sources/CoreATProtocol/OAuth/OAuthUIProvider.swift new file mode 100644 index 0000000..bba6801 --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/OAuthUIProvider.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol OAuthUIProvider: Sendable { + func presentAuthorization(at url: URL, callbackScheme: String) async throws -> URL +} diff --git a/Sources/CoreATProtocol/OAuth/Security/DPoPGenerator.swift b/Sources/CoreATProtocol/OAuth/Security/DPoPGenerator.swift new file mode 100644 index 0000000..9826a27 --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/Security/DPoPGenerator.swift @@ -0,0 +1,101 @@ +import CryptoKit +import Foundation + +enum DPoPGeneratorError: Error, Sendable { + case invalidURL + case keyUnavailable +} + +@APActor +public final class DPoPGenerator: Sendable { + private var keyPair: DPoPKeyPair + private let clock: () -> Date + + init(keyPair: DPoPKeyPair, clock: @escaping () -> Date = Date.init) { + self.keyPair = keyPair + self.clock = clock + } + + public convenience init(clock: @escaping () -> Date = Date.init) { + self.init(keyPair: DPoPKeyPair(), clock: clock) + } + + public func updateKey(using rawRepresentation: Data) throws { + self.keyPair = try DPoPKeyPair(rawRepresentation: rawRepresentation) + } + + public func exportKey() -> Data { + keyPair.export() + } + + public func generateProof( + method: String, + url: URL, + nonce: String?, + accessToken: String? + ) throws -> String { + let normalizedHTU = try normalize(url: url) + let issuedAt = Int(clock().timeIntervalSince1970) + let header = Header(jwk: keyPair.publicKeyJWK) + let payload = Payload( + htm: method.uppercased(), + htu: normalizedHTU, + iat: issuedAt, + exp: issuedAt + 120, + jti: UUID().uuidString, + nonce: nonce, + ath: accessToken.flatMap { accessTokenHash(for: $0) } + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + let headerEncoded = Base64URL.encode(try encoder.encode(header)) + let payloadEncoded = Base64URL.encode(try encoder.encode(payload)) + let signingInput = Data("\(headerEncoded).\(payloadEncoded)".utf8) + let signature = try keyPair.privateKey.signature(for: signingInput) + let signatureEncoded = Base64URL.encode(signature.derRepresentation) + return "\(headerEncoded).\(payloadEncoded).\(signatureEncoded)" + } + + private func normalize(url: URL) throws -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw DPoPGeneratorError.invalidURL + } + components.fragment = nil + guard let normalized = components.url?.absoluteString else { + throw DPoPGeneratorError.invalidURL + } + return normalized + } + + private func accessTokenHash(for token: String) -> String { + let digest = SHA256.hash(data: Data(token.utf8)) + return Base64URL.encode(Data(digest)) + } + + private struct Header: Encodable { + let typ = "dpop+jwt" + let alg = "ES256" + let jwk: [String: String] + } + + private struct Payload: Encodable { + let htm: String + let htu: String + let iat: Int + let exp: Int + let jti: String + let nonce: String? + let ath: String? + + private enum CodingKeys: String, CodingKey { + case htm + case htu + case iat + case exp + case jti + case nonce + case ath + } + } +} diff --git a/Sources/CoreATProtocol/OAuth/Security/DPoPKeyPair.swift b/Sources/CoreATProtocol/OAuth/Security/DPoPKeyPair.swift new file mode 100644 index 0000000..8c603d2 --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/Security/DPoPKeyPair.swift @@ -0,0 +1,38 @@ +import CryptoKit +import Foundation + +struct DPoPKeyPair: Sendable { + let privateKey: P256.Signing.PrivateKey + + init() { + self.privateKey = P256.Signing.PrivateKey() + } + + init(privateKey: P256.Signing.PrivateKey) { + self.privateKey = privateKey + } + + init(rawRepresentation: Data) throws { + self.privateKey = try P256.Signing.PrivateKey(rawRepresentation: rawRepresentation) + } + + var publicKeyJWK: [String: String] { + let publicKeyData = privateKey.publicKey.x963Representation + // Strip leading 0x04 per SEC1 encoding to expose affine coordinates + let xData = Data(publicKeyData[1..<33]) + let yData = Data(publicKeyData[33..<65]) + + return [ + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "use": "sig", + "x": Base64URL.encode(xData), + "y": Base64URL.encode(yData) + ] + } + + func export() -> Data { + privateKey.rawRepresentation + } +} diff --git a/Sources/CoreATProtocol/OAuth/Security/PKCEGenerator.swift b/Sources/CoreATProtocol/OAuth/Security/PKCEGenerator.swift new file mode 100644 index 0000000..fc20530 --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/Security/PKCEGenerator.swift @@ -0,0 +1,38 @@ +import CryptoKit +import Foundation + +struct PKCEValues: Sendable { + let verifier: String + let challenge: String +} + +struct PKCEGenerator: Sendable { + private let randomGenerator: RandomDataGenerating + + init(randomGenerator: RandomDataGenerating = SecureRandomDataGenerator()) { + self.randomGenerator = randomGenerator + } + + func makeValues() throws -> PKCEValues { + let verifier = try makeVerifier() + let challenge = makeChallenge(from: verifier) + return PKCEValues(verifier: verifier, challenge: challenge) + } + + func makeVerifier() throws -> String { + let candidateLengths = [32, 48, 64] + for length in candidateLengths { + let data = try randomGenerator.data(count: length) + let candidate = Base64URL.encode(data) + if (43...128).contains(candidate.count) { + return candidate + } + } + throw RandomDataGeneratorError.allocationFailed + } + + func makeChallenge(from verifier: String) -> String { + let digest = SHA256.hash(data: Data(verifier.utf8)) + return Base64URL.encode(Data(digest)) + } +} diff --git a/Sources/CoreATProtocol/OAuth/Security/RandomDataGenerator.swift b/Sources/CoreATProtocol/OAuth/Security/RandomDataGenerator.swift new file mode 100644 index 0000000..beec42d --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/Security/RandomDataGenerator.swift @@ -0,0 +1,23 @@ +import Foundation +import Security + +enum RandomDataGeneratorError: Error, Sendable { + case allocationFailed + case generationFailed(status: OSStatus) +} + +protocol RandomDataGenerating: Sendable { + func data(count: Int) throws -> Data +} + +struct SecureRandomDataGenerator: RandomDataGenerating { + func data(count: Int) throws -> Data { + guard count > 0 else { return Data() } + var buffer = Data(count: count) + let status = buffer.withUnsafeMutableBytes { pointer in + SecRandomCopyBytes(kSecRandomDefault, count, pointer.baseAddress!) + } + guard status == errSecSuccess else { throw RandomDataGeneratorError.generationFailed(status: status) } + return buffer + } +} diff --git a/Sources/CoreATProtocol/OAuth/Storage/OAuthCredentialStore.swift b/Sources/CoreATProtocol/OAuth/Storage/OAuthCredentialStore.swift new file mode 100644 index 0000000..cef060a --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/Storage/OAuthCredentialStore.swift @@ -0,0 +1,41 @@ +import Foundation + +public protocol OAuthCredentialStore: Sendable { + func loadSession() async throws -> OAuthSession? + func save(session: OAuthSession) async throws + func deleteSession() async throws + func loadDPoPKey() async throws -> Data? + func saveDPoPKey(_ data: Data) async throws + func deleteDPoPKey() async throws +} + +public actor InMemoryOAuthCredentialStore: OAuthCredentialStore { + private var session: OAuthSession? + private var dpopKey: Data? + + public init() {} + + public func loadSession() async throws -> OAuthSession? { + session + } + + public func save(session: OAuthSession) async throws { + self.session = session + } + + public func deleteSession() async throws { + session = nil + } + + public func loadDPoPKey() async throws -> Data? { + dpopKey + } + + public func saveDPoPKey(_ data: Data) async throws { + dpopKey = data + } + + public func deleteDPoPKey() async throws { + dpopKey = nil + } +} diff --git a/Sources/CoreATProtocol/OAuth/Utilities/Base64URL.swift b/Sources/CoreATProtocol/OAuth/Utilities/Base64URL.swift new file mode 100644 index 0000000..dea3fa6 --- /dev/null +++ b/Sources/CoreATProtocol/OAuth/Utilities/Base64URL.swift @@ -0,0 +1,26 @@ +import Foundation + +enum Base64URLError: Error, Sendable { + case invalidLength + case invalidCharacters +} + +struct Base64URL: Sendable { + static func encode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func decode(_ string: String) throws -> Data { + let remainder = string.count % 4 + guard remainder != 1 else { throw Base64URLError.invalidLength } + let paddingLength = remainder == 0 ? 0 : 4 - remainder + let padded = string + String(repeating: "=", count: paddingLength) + guard let data = Data(base64Encoded: padded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")) else { + throw Base64URLError.invalidCharacters + } + return data + } +} diff --git a/Tests/CoreATProtocolTests/OAuthSecurityTests.swift b/Tests/CoreATProtocolTests/OAuthSecurityTests.swift new file mode 100644 index 0000000..2250d9a --- /dev/null +++ b/Tests/CoreATProtocolTests/OAuthSecurityTests.swift @@ -0,0 +1,97 @@ +import CryptoKit +import Foundation +import Testing +@testable import CoreATProtocol + +private struct DeterministicRandomGenerator: RandomDataGenerating { + func data(count: Int) throws -> Data { + Data(repeating: 0x42, count: count) + } +} + +@Test("Base64URL encodes without padding and decodes back") +func base64URLRoundTrip() throws { + let data = Data([0xde, 0xad, 0xbe, 0xef]) + let encoded = Base64URL.encode(data) + #expect(encoded.contains("=") == false) + let decoded = try Base64URL.decode(encoded) + #expect(decoded == data) +} + +@Test("PKCE generator creates verifier within bounds and matching challenge") +func pkceGeneratorProducesExpectedValues() throws { + let generator = PKCEGenerator(randomGenerator: DeterministicRandomGenerator()) + let values = try generator.makeValues() + #expect(values.verifier.count >= 43) + #expect(values.verifier.count <= 128) + + let expectedDigest = SHA256.hash(data: Data(values.verifier.utf8)) + let expectedChallenge = Base64URL.encode(Data(expectedDigest)) + #expect(values.challenge == expectedChallenge) +} + +@Test("DPoP generator signs payload with expected claims") +func dpopGeneratorProducesValidProof() async throws { + let keyPair = DPoPKeyPair() + let generator = await DPoPGenerator(clock: { Date(timeIntervalSince1970: 1_700_000_000) }) + try await generator.updateKey(using: keyPair.export()) + let url = URL(string: "https://example.com/resource")! + let proof = try await generator.generateProof( + method: "GET", + url: url, + nonce: "nonce-value", + accessToken: "access-token" + ) + + let components = proof.split(separator: ".") + #expect(components.count == 3) + + let headerData = try Base64URL.decode(String(components[0])) + let payloadData = try Base64URL.decode(String(components[1])) + let signatureData = try Base64URL.decode(String(components[2])) + + let header = try JSONSerialization.jsonObject(with: headerData) as? [String: Any] + let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any] + + #expect(header?["typ"] as? String == "dpop+jwt") + #expect(header?["alg"] as? String == "ES256") + let jwk = header?["jwk"] as? [String: String] + #expect(jwk?["kty"] == "EC") + #expect(jwk?["crv"] == "P-256") + + #expect(payload?["htm"] as? String == "GET") + #expect(payload?["htu"] as? String == "https://example.com/resource") + #expect(payload?["nonce"] as? String == "nonce-value") + #expect(payload?["ath"] as? String == Base64URL.encode(Data(SHA256.hash(data: Data("access-token".utf8))))) + + if let iat = payload?["iat"] as? Int { + #expect(iat == 1_700_000_000) + } else { + Issue.record("DPoP payload missing iat") + } + + let signingInput = Data((components[0] + "." + components[1]).utf8) + let signature = try P256.Signing.ECDSASignature(derRepresentation: signatureData) + #expect(keyPair.privateKey.publicKey.isValidSignature(signature, for: signingInput)) +} + +@Test("OAuth session refresh heuristics") +func oauthSessionRefreshLogic() { + let issuedAt = Date() + let session = OAuthSession( + did: "did:plc:example", + pdsURL: URL(string: "https://pds.example.com")!, + authorizationServer: URL(string: "https://auth.example.com")!, + tokenEndpoint: URL(string: "https://auth.example.com/token")!, + accessToken: "token", + refreshToken: "refresh", + tokenType: "DPoP", + scope: "atproto", + expiresIn: 3600, + issuedAt: issuedAt + ) + + #expect(session.isExpired(relativeTo: issuedAt.addingTimeInterval(3500)) == false) + #expect(session.needsRefresh(relativeTo: issuedAt.addingTimeInterval(3300), threshold: 400)) + #expect(session.isExpired(relativeTo: issuedAt.addingTimeInterval(3600))) +}