diff --git a/Package.resolved b/Package.resolved --- a/Package.resolved +++ b/Package.resolved @@ -16,7 +16,7 @@ "location" : "https://github.com/SparrowTek/NetworkingKit.git", "state" : { "branch" : "main", - "revision" : "d5a53afb86b64a70b3611590dec2dd21d6d84333" + "revision" : "9f3b3147ec60ad869a6079c58b0aabcde8e174da" } }, { diff --git a/Sources/CoreATProtocol/Networking.swift b/Sources/CoreATProtocol/Networking.swift --- a/Sources/CoreATProtocol/Networking.swift +++ b/Sources/CoreATProtocol/Networking.swift @@ -6,6 +6,7 @@ // import Foundation +import Crypto import JWTKit import NetworkingKit @@ -55,7 +56,7 @@ // DPoP-bound token: use "DPoP" scheme + DPoP proof header request.setValue("DPoP \(accessToken)", forHTTPHeaderField: "Authorization") - if let proof = await generateDPoPProof(for: request, privateKey: dpopKey, keys: keys) { + if let proof = await generateDPoPProof(for: request, accessToken: accessToken, privateKey: dpopKey, keys: keys) { request.setValue(proof, forHTTPHeaderField: "DPoP") } } else { @@ -66,6 +67,7 @@ private func generateDPoPProof( for request: URLRequest, + accessToken: String, privateKey: ES256PrivateKey, keys: JWTKeyCollection ) async -> String? { @@ -80,12 +82,20 @@ let nonce = await APEnvironment.current.dpopNonce + // ath: base64url-encoded SHA-256 hash of the access token (RFC 9449 §4.2) + let hash = SHA256.hash(data: Data(accessToken.utf8)) + let ath = Data(hash).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + let payload = DPoPProofPayload( htm: method, htu: htu, iat: .init(value: .now), jti: .init(value: UUID().uuidString), - nonce: nonce + nonce: nonce, + ath: ath ) var header = JWTHeader() @@ -114,8 +124,10 @@ } public func didReceiveErrorResponse(_ response: HTTPURLResponse) async { - if let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce") - ?? response.value(forHTTPHeaderField: "dpop-nonce") { + let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce") + ?? response.value(forHTTPHeaderField: "dpop-nonce") + debugPrint("[APRouterDelegate] didReceiveErrorResponse: status=\(response.statusCode), DPoP-Nonce=\(nonce ?? "nil")") + if let nonce { await storeDPoPNonce(nonce) } } @@ -126,65 +138,92 @@ } public func shouldRetry(error: Error, attempts: Int) async throws -> Bool { - guard attempts == 1 else { return false } - - func refreshViaOAuth() async throws -> Bool { - guard let handler = await APEnvironment.current.tokenRefreshHandler else { - return false - } - - if let refreshTask { - return try await refreshTask.value - } - - let task = Task { try await handler() } - refreshTask = task - - defer { refreshTask = nil } - - return try await task.value + guard attempts <= 2 else { + debugPrint("[APRouterDelegate] shouldRetry: attempts=\(attempts), giving up") + return false } - // Check for DPoP nonce challenge — retry immediately without token refresh - // since didReceiveErrorResponse already stored the nonce. + // Extract error body for logging + let errorBody: String if let networkError = error as? NetworkError, - case .statusCode(_, let data, _) = networkError, - isDPoPNonceError(data) { + case .statusCode(_, let d, _) = networkError { + errorBody = String(data: d, encoding: .utf8) ?? "" + } else { + errorBody = String(describing: error) + } + debugPrint("[APRouterDelegate] shouldRetry: attempts=\(attempts), body=\(errorBody)") + + // DPoP nonce challenge — retry immediately without token refresh. + // Allowed on any attempt since a nonce challenge can follow a token refresh. + if isDPoPNonceError(from: error) { + debugPrint("[APRouterDelegate] shouldRetry: DPoP nonce challenge, retrying") return true } - if case .network(let networkError) = error as? AtError, - case .statusCode(_, let data, _) = networkError, - isDPoPNonceError(data) { - return true + // Token expired or unauthorized — attempt OAuth refresh (first attempt only). + guard attempts == 1 else { + debugPrint("[APRouterDelegate] shouldRetry: attempts > 1 and not a nonce error, giving up") + return false } - // Token expired or unauthorized — attempt OAuth refresh if case .network(let networkError) = error as? AtError, case .statusCode(let statusCode, _, _) = networkError, let statusCode = statusCode?.rawValue, statusCode == 401 || statusCode == 403 { + debugPrint("[APRouterDelegate] shouldRetry: AtError 401/403, refreshing OAuth") return try await refreshViaOAuth() } if case .statusCode(let statusCode, _, _) = error as? NetworkError, let statusCode = statusCode?.rawValue, statusCode == 401 || statusCode == 403 { + debugPrint("[APRouterDelegate] shouldRetry: NetworkError 401/403, refreshing OAuth") return try await refreshViaOAuth() } if case .message(let message) = error as? AtError, message.error == AtErrorType.expiredToken.rawValue { + debugPrint("[APRouterDelegate] shouldRetry: ExpiredToken, refreshing OAuth") return try await refreshViaOAuth() } + debugPrint("[APRouterDelegate] shouldRetry: no matching case, not retrying") return false } - private func isDPoPNonceError(_ data: Data) -> Bool { - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let error = json["error"] as? String else { return false } - return error == "use_dpop_nonce" + private func refreshViaOAuth() async throws -> Bool { + guard let handler = await APEnvironment.current.tokenRefreshHandler else { + return false + } + + if let refreshTask { + return try await refreshTask.value + } + + let task = Task { try await handler() } + refreshTask = task + + defer { refreshTask = nil } + + return try await task.value + } + + private func isDPoPNonceError(from error: Error) -> Bool { + let data: Data? + if let networkError = error as? NetworkError, + case .statusCode(_, let d, _) = networkError { + data = d + } else if case .network(let networkError) = error as? AtError, + case .statusCode(_, let d, _) = networkError { + data = d + } else { + data = nil + } + + guard let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let errorType = json["error"] as? String else { return false } + return errorType == "use_dpop_nonce" } } @@ -196,6 +235,7 @@ let iat: IssuedAtClaim let jti: IDClaim let nonce: String? + let ath: String? func verify(using key: some JWTAlgorithm) throws {} }