diff --git a/Package.resolved b/Package.resolved index 991916d..995f309 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1139e0e1075c4de720978803490da5da789019e0504be736dd4f216da7eadad4", + "originHash" : "2237e2c10a8d530dcbd1f9770efc8fcf2a9fc2ca2c63a19882551fea7ab9fe25", "pins" : [ { "identity" : "jwt-kit", diff --git a/Sources/CoreATProtocol/APEnvironment.swift b/Sources/CoreATProtocol/APEnvironment.swift index d5acbed..5d7855b 100644 --- a/Sources/CoreATProtocol/APEnvironment.swift +++ b/Sources/CoreATProtocol/APEnvironment.swift @@ -5,6 +5,8 @@ // Created by Thomas Rademaker on 10/10/25. // +import OAuthenticator + @APActor public class APEnvironment { public static var current: APEnvironment = APEnvironment() @@ -12,8 +14,12 @@ public class APEnvironment { public var host: String? public var accessToken: String? public var refreshToken: String? + public var login: Login? + public var dpopProofGenerator: DPoPSigner.JWTGenerator? + public var resourceServerNonce: String? public var atProtocoldelegate: CoreATProtocolDelegate? public let routerDelegate = APRouterDelegate() + public let resourceDPoPSigner = DPoPSigner() private init() {} @@ -23,4 +29,3 @@ public class APEnvironment { // self.userAgent = userAgent // } } - diff --git a/Sources/CoreATProtocol/CoreATProtocol.swift b/Sources/CoreATProtocol/CoreATProtocol.swift index 9f21348..34f3b6f 100644 --- a/Sources/CoreATProtocol/CoreATProtocol.swift +++ b/Sources/CoreATProtocol/CoreATProtocol.swift @@ -1,6 +1,8 @@ // The Swift Programming Language // https://docs.swift.org/swift-book +@_exported import OAuthenticator + public protocol CoreATProtocolDelegate: AnyObject {} @APActor @@ -26,3 +28,29 @@ public func updateTokens(access: String?, refresh: String?) { public func update(hostURL: String?) { APEnvironment.current.host = hostURL } + +@APActor +public func applyAuthenticationContext(login: Login, generator: @escaping DPoPSigner.JWTGenerator, resourceNonce: String? = nil) { + APEnvironment.current.login = login + APEnvironment.current.accessToken = login.accessToken.value + APEnvironment.current.refreshToken = login.refreshToken?.value + APEnvironment.current.dpopProofGenerator = generator + APEnvironment.current.resourceServerNonce = resourceNonce + APEnvironment.current.resourceDPoPSigner.nonce = resourceNonce +} + +@APActor +public func clearAuthenticationContext() { + APEnvironment.current.login = nil + APEnvironment.current.dpopProofGenerator = nil + APEnvironment.current.resourceServerNonce = nil + APEnvironment.current.accessToken = nil + APEnvironment.current.refreshToken = nil + APEnvironment.current.resourceDPoPSigner.nonce = nil +} + +@APActor +public func updateResourceDPoPNonce(_ nonce: String?) { + APEnvironment.current.resourceServerNonce = nonce + APEnvironment.current.resourceDPoPSigner.nonce = nonce +} diff --git a/Sources/CoreATProtocol/DPoPJWTGenerator.swift b/Sources/CoreATProtocol/DPoPJWTGenerator.swift new file mode 100644 index 0000000..fdde428 --- /dev/null +++ b/Sources/CoreATProtocol/DPoPJWTGenerator.swift @@ -0,0 +1,83 @@ +import Foundation +import JWTKit +import OAuthenticator + +public enum DPoPKeyMaterialError: Error, Equatable { + case publicKeyUnavailable + case invalidCoordinate +} + +public actor DPoPJWTGenerator { + private let privateKey: ES256PrivateKey + private let keys: JWTKeyCollection + private let jwkHeader: [String: JWTHeaderField] + + public init(privateKey: ES256PrivateKey) async throws { + self.privateKey = privateKey + self.keys = JWTKeyCollection() + self.jwkHeader = try Self.makeJWKHeader(from: privateKey) + await self.keys.add(ecdsa: privateKey) + } + + public func jwtGenerator() -> DPoPSigner.JWTGenerator { + { params in + try await self.makeJWT(for: params) + } + } + + public func makeJWT(for params: DPoPSigner.JWTParameters) async throws -> String { + var header = JWTHeader() + header.typ = params.keyType + header.alg = header.alg ?? "ES256" + header.jwk = jwkHeader + + let issuedAt = Date() + let payload = DPoPPayload( + htm: params.httpMethod, + htu: params.requestEndpoint, + iat: IssuedAtClaim(value: issuedAt), + exp: ExpirationClaim(value: issuedAt.addingTimeInterval(60)), + jti: IDClaim(value: UUID().uuidString), + nonce: params.nonce, + iss: params.issuingServer.map { IssuerClaim(value: $0) }, + ath: params.tokenHash + ) + + return try await keys.sign(payload, header: header) + } + + private static func makeJWKHeader(from key: ES256PrivateKey) throws -> [String: JWTHeaderField] { + guard let parameters = key.publicKey.parameters else { + throw DPoPKeyMaterialError.publicKeyUnavailable + } + + guard + let xData = Data(base64Encoded: parameters.x), + let yData = Data(base64Encoded: parameters.y) + else { + throw DPoPKeyMaterialError.invalidCoordinate + } + + return [ + "kty": .string("EC"), + "crv": .string("P-256"), + "x": .string(xData.base64URLEncodedString()), + "y": .string(yData.base64URLEncodedString()) + ] + } +} + +struct DPoPPayload: JWTPayload { + let htm: String + let htu: String + let iat: IssuedAtClaim + let exp: ExpirationClaim + let jti: IDClaim + let nonce: String? + let iss: IssuerClaim? + let ath: String? + + func verify(using key: some JWTAlgorithm) throws { + try exp.verifyNotExpired(currentDate: Date()) + } +} diff --git a/Sources/CoreATProtocol/Extensions/Data+Base64URL.swift b/Sources/CoreATProtocol/Extensions/Data+Base64URL.swift new file mode 100644 index 0000000..d968d86 --- /dev/null +++ b/Sources/CoreATProtocol/Extensions/Data+Base64URL.swift @@ -0,0 +1,11 @@ +import Foundation + +extension Data { + /// Returns a URL-safe Base64 representation without padding. + func base64URLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/Sources/CoreATProtocol/LoginService.swift b/Sources/CoreATProtocol/LoginService.swift index 5876b80..8c47bd6 100644 --- a/Sources/CoreATProtocol/LoginService.swift +++ b/Sources/CoreATProtocol/LoginService.swift @@ -7,23 +7,22 @@ import Foundation import OAuthenticator -import JWTKit -import CryptoKit @APActor -class LoginService { - private var keys: JWTKeyCollection - private var privateKey: ES256PrivateKey - - public init() async { - // Create keys once during initialization - self.privateKey = ES256PrivateKey() - self.keys = JWTKeyCollection() - // Add the key to the collection - await self.keys.add(ecdsa: privateKey) +public final class LoginService { + public enum Error: Swift.Error { + case missingStoredLogin } - - public func login(account: String, clientMetadataEndpoint: String) async throws { + + private let loginStorage: LoginStorage + private let jwtGenerator: DPoPSigner.JWTGenerator + + public init(jwtGenerator: @escaping DPoPSigner.JWTGenerator, loginStorage: LoginStorage) { + self.jwtGenerator = jwtGenerator + self.loginStorage = loginStorage + } + + public func login(account: String, clientMetadataEndpoint: String) async throws -> Login { let provider = URLSession.defaultProvider let host = APEnvironment.current.host ?? "" let server = if host.hasPrefix("https://") { @@ -34,52 +33,16 @@ class LoginService { let clientConfig = try await ClientMetadata.load(for: clientMetadataEndpoint, provider: provider) let serverConfig = try await ServerMetadata.load(for: server, provider: provider) - - // Create storage for persisting login state - let loginStorage = LoginStorage { - // Implement retrieving stored login - // Return stored Login if it exists, or nil - return nil - } storeLogin: { login in - // Implement storing the login - // Store the login securely - - print("LOGIN: \(login)") - } - - let jwtGenerator: DPoPSigner.JWTGenerator = { params in - try await self.generateJWT(params: params) - } let tokenHandling = Bluesky.tokenHandling(account: account, server: serverConfig, jwtGenerator: jwtGenerator) let config = Authenticator.Configuration(appCredentials: clientConfig.credentials, loginStorage: loginStorage, tokenHandling: tokenHandling, mode: .automatic) let authenticator = Authenticator(config: config) try await authenticator.authenticate() - } - - private func generateJWT(params: DPoPSigner.JWTParameters) async throws -> String { - // Create DPoP payload using existing keys - let payload = DPoPPayload( - htm: params.httpMethod, - htu: params.requestEndpoint, - iat: .init(value: .now), - jti: .init(value: UUID().uuidString), - nonce: params.nonce - ) - - // Sign with existing keys - return try await self.keys.sign(payload) - } -} -private struct DPoPPayload: JWTPayload { - let htm: String - let htu: String - let iat: IssuedAtClaim - let jti: IDClaim - let nonce: String? - - func verify(using key: some JWTAlgorithm) throws { - // No additional verification needed + guard let storedLogin = try await loginStorage.retrieveLogin() else { + throw Error.missingStoredLogin + } + + return storedLogin } } diff --git a/Sources/CoreATProtocol/Networking.swift b/Sources/CoreATProtocol/Networking.swift index a9c0fb4..c31c58e 100644 --- a/Sources/CoreATProtocol/Networking.swift +++ b/Sources/CoreATProtocol/Networking.swift @@ -6,6 +6,8 @@ // import Foundation +import CryptoKit +@preconcurrency import OAuthenticator extension JSONDecoder { public static var atDecoder: JSONDecoder { @@ -30,15 +32,39 @@ func shouldPerformRequest(lastFetched: Double, timeLimit: Int = 3600) -> Bool { return differenceInMinutes >= timeLimit } -@APActor +@MainActor public class APRouterDelegate: NetworkRouterDelegate { private var shouldRefreshToken = false public func intercept(_ request: inout URLRequest) async { - if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken { + if let generator = await APEnvironment.current.dpopProofGenerator, + let login = await APEnvironment.current.login { + let token = login.accessToken.value + let tokenHash = tokenHash(for: token) + let signer = await APEnvironment.current.resourceDPoPSigner + signer.nonce = await APEnvironment.current.resourceServerNonce + + do { + try await signer.authenticateRequest( + &request, + isolation: MainActor.shared, + using: generator, + token: token, + tokenHash: tokenHash, + issuer: login.issuingServer + ) + } catch { + // If DPoP signing fails, fall back to providing the token directly. + request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization") + } + + return + } + + if let refreshToken = await APEnvironment.current.refreshToken, shouldRefreshToken { shouldRefreshToken = false request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization") - } else if let accessToken = APEnvironment.current.accessToken { + } else if let accessToken = await APEnvironment.current.accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } } @@ -65,7 +91,12 @@ public class APRouterDelegate: NetworkRouterDelegate { message.error == AtErrorType.expiredToken.rawValue { return try await getNewToken() } - + return false } + + private func tokenHash(for token: String) -> String { + let digest = SHA256.hash(data: Data(token.utf8)) + return Data(digest).base64URLEncodedString() + } } diff --git a/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift b/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift index 366ea0f..8043d90 100644 --- a/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift +++ b/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift @@ -1,6 +1,6 @@ import Foundation -@APActor +@MainActor public protocol NetworkRouterDelegate: AnyObject { func intercept(_ request: inout URLRequest) async func shouldRetry(error: Error, attempts: Int) async throws -> Bool