diff --git a/Sources/CoreATProtocol/APEnvironment.swift b/Sources/CoreATProtocol/APEnvironment.swift index 71de425..19e10f7 100644 --- a/Sources/CoreATProtocol/APEnvironment.swift +++ b/Sources/CoreATProtocol/APEnvironment.swift @@ -5,6 +5,8 @@ // Created by Thomas Rademaker on 10/10/25. // +import JWTKit + @APActor public class APEnvironment { public static var current: APEnvironment = APEnvironment() @@ -14,6 +16,8 @@ public class APEnvironment { public var refreshToken: String? public var atProtocoldelegate: CoreATProtocolDelegate? public var tokenRefreshHandler: (@Sendable () async throws -> Bool)? + public var dpopPrivateKey: ES256PrivateKey? + public var dpopKeys: JWTKeyCollection? public let routerDelegate = APRouterDelegate() private init() {} diff --git a/Sources/CoreATProtocol/CoreATProtocol.swift b/Sources/CoreATProtocol/CoreATProtocol.swift index 21061ac..dba0263 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 +import JWTKit + // MARK: - Session /// Represents an authenticated AT Protocol session @@ -50,6 +52,22 @@ public func setTokenRefreshHandler(_ handler: (@Sendable () async throws -> Bool APEnvironment.current.tokenRefreshHandler = handler } +@APActor +public func setDPoPPrivateKey(pem: String?) async throws { + guard let pem, !pem.isEmpty else { + APEnvironment.current.dpopPrivateKey = nil + APEnvironment.current.dpopKeys = nil + return + } + + let privateKey = try ES256PrivateKey(pem: pem) + let keys = JWTKeyCollection() + await keys.add(ecdsa: privateKey) + + APEnvironment.current.dpopPrivateKey = privateKey + APEnvironment.current.dpopKeys = keys +} + @APActor public func updateTokens(access: String?, refresh: String?) { APEnvironment.current.accessToken = access diff --git a/Sources/CoreATProtocol/Networking.swift b/Sources/CoreATProtocol/Networking.swift index d060ad6..92d9716 100644 --- a/Sources/CoreATProtocol/Networking.swift +++ b/Sources/CoreATProtocol/Networking.swift @@ -36,6 +36,10 @@ public class APRouterDelegate: NetworkRouterDelegate { private var refreshTask: Task? public func intercept(_ request: inout URLRequest) async { + if APEnvironment.current.dpopPrivateKey != nil { + return + } + if let refreshToken = APEnvironment.current.refreshToken, shouldRefreshToken { shouldRefreshToken = false request.setValue("Bearer \(refreshToken)", forHTTPHeaderField: "Authorization") diff --git a/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift b/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift index 366ea0f..8f0bec0 100644 --- a/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift +++ b/Sources/CoreATProtocol/Networking/Services/NetworkRouter.swift @@ -1,4 +1,11 @@ import Foundation +import JWTKit +import OAuthenticator +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif @APActor public protocol NetworkRouterDelegate: AnyObject { @@ -36,6 +43,7 @@ public class NetworkRouter: NetworkRouterProtocol { let networking: Networking let urlSessionTaskDelegate: URLSessionTaskDelegate? var decoder: JSONDecoder + private let dpopActor = DPoPRequestActor() public init(networking: Networking? = nil, urlSessionDelegate: URLSessionDelegate? = nil, urlSessionTaskDelegate: URLSessionTaskDelegate? = nil, decoder: JSONDecoder? = nil) { if let networking = networking { @@ -61,7 +69,7 @@ public class NetworkRouter: NetworkRouterProtocol { guard var request = try? await buildRequest(from: route) else { throw NetworkError.encodingFailed } await delegate?.intercept(&request) - let (data, response) = try await networking.data(for: request, delegate: urlSessionTaskDelegate) + let (data, response) = try await executeRequest(request) guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.noStatusCode } switch httpResponse.statusCode { case 200...299: @@ -85,6 +93,112 @@ public class NetworkRouter: NetworkRouterProtocol { return try await execute(route, attempts: attempts + 1) } } + + private func executeRequest(_ request: URLRequest) async throws -> (Data, URLResponse) { + if let accessToken = APEnvironment.current.accessToken, + let privateKey = APEnvironment.current.dpopPrivateKey, + let keys = APEnvironment.current.dpopKeys { + return try await dpopResponse( + for: request, + accessToken: accessToken, + privateKey: privateKey, + keys: keys + ) + } + + return try await networking.data(for: request, delegate: urlSessionTaskDelegate) + } + + private func dpopResponse( + for request: URLRequest, + accessToken: String, + privateKey: ES256PrivateKey, + keys: JWTKeyCollection + ) async throws -> (Data, URLResponse) { + let tokenHash = hashToken(accessToken) + let jwtGenerator: DPoPSigner.JWTGenerator = { params in + try await self.generateDPoPJWT( + params: params, + tokenHash: tokenHash, + privateKey: privateKey, + keys: keys + ) + } + + let responseProvider: URLResponseProvider = { request in + try await self.networking.data(for: request, delegate: nil) + } + + return try await dpopActor.response( + request: request, + jwtGenerator: jwtGenerator, + token: accessToken, + tokenHash: tokenHash, + provider: responseProvider + ) + } + + private func generateDPoPJWT( + params: DPoPSigner.JWTParameters, + tokenHash: String, + privateKey: ES256PrivateKey, + keys: JWTKeyCollection + ) async throws -> String { + let htu = stripQueryAndFragment(from: params.requestEndpoint) + let payload = DPoPRequestPayload( + htm: params.httpMethod, + htu: htu, + iat: .init(value: .now), + jti: .init(value: UUID().uuidString), + nonce: params.nonce, + ath: tokenHash + ) + + var header = JWTHeader() + header.typ = "dpop+jwt" + header.alg = "ES256" + + if let keyParams = privateKey.parameters { + let xBase64URL = keyParams.x + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + let yBase64URL = keyParams.y + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + header.jwk = [ + "kty": .string("EC"), + "crv": .string("P-256"), + "x": .string(xBase64URL), + "y": .string(yBase64URL) + ] + } + + return try await keys.sign(payload, header: header) + } + + private func stripQueryAndFragment(from url: String) -> String { + let fragmentIndex = url.firstIndex(of: "#").map { url.distance(from: url.startIndex, to: $0) } ?? -1 + let queryIndex = url.firstIndex(of: "?").map { url.distance(from: url.startIndex, to: $0) } ?? -1 + + let end: Int + if fragmentIndex == -1 { + end = queryIndex + } else if queryIndex == -1 { + end = fragmentIndex + } else { + end = min(fragmentIndex, queryIndex) + } + + return end == -1 ? url : String(url.prefix(end)) + } + + private func hashToken(_ token: String) -> String { + let digest = SHA256.hash(data: Data(token.utf8)) + return Data(digest).base64URLEncodedString() + } func buildRequest(from route: Endpoint) async throws -> URLRequest { @@ -119,3 +233,47 @@ public class NetworkRouter: NetworkRouterProtocol { } } } + +private struct DPoPRequestPayload: JWTPayload { + let htm: String + let htu: String + let iat: IssuedAtClaim + let jti: IDClaim + let nonce: String? + let ath: String? + + func verify(using key: some JWTAlgorithm) throws { + // No additional verification needed for DPoP + } +} + +private actor DPoPRequestActor { + private let signer = DPoPSigner() + + func response( + request: URLRequest, + jwtGenerator: DPoPSigner.JWTGenerator, + token: String, + tokenHash: String, + provider: URLResponseProvider + ) async throws -> (Data, URLResponse) { + try await signer.response( + isolation: self, + for: request, + using: jwtGenerator, + token: token, + tokenHash: tokenHash, + issuingServer: nil, + provider: provider + ) + } +} + +private extension Data { + func base64URLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/Sources/CoreATProtocol/Networking/Services/NetworkingProtocol.swift b/Sources/CoreATProtocol/Networking/Services/NetworkingProtocol.swift index a9aa644..2e16a78 100644 --- a/Sources/CoreATProtocol/Networking/Services/NetworkingProtocol.swift +++ b/Sources/CoreATProtocol/Networking/Services/NetworkingProtocol.swift @@ -1,7 +1,7 @@ @preconcurrency import Foundation @APActor -public protocol Networking { +public protocol Networking: Sendable { func data(for request: URLRequest, delegate: URLSessionTaskDelegate?) async throws -> (Data, URLResponse) }