···1818 public var tokenRefreshHandler: (@Sendable () async throws -> Bool)?
1919 public var dpopPrivateKey: ES256PrivateKey?
2020 public var dpopKeys: JWTKeyCollection?
2121- public var dpopNonce: String?
2121+ public let dpopNonceStore = DPoPNonceStore()
2222 public let routerDelegate = APRouterDelegate()
23232424 private init() {}
+23
Sources/CoreATProtocol/DPoPNonceStore.swift
···11+//
22+// DPoPNonceStore.swift
33+// CoreATProtocol
44+//
55+66+/// Serialises reads and writes to the DPoP server-issued nonce.
77+///
88+/// RFC 9449 allows a server to rotate the DPoP nonce on any response. Multiple
99+/// in-flight requests can observe a nonce update concurrently, so a dedicated
1010+/// actor is used to keep the read/update pair ordered.
1111+public actor DPoPNonceStore {
1212+ private var nonce: String?
1313+1414+ public init(nonce: String? = nil) {
1515+ self.nonce = nonce
1616+ }
1717+1818+ public func get() -> String? { nonce }
1919+2020+ public func update(_ nonce: String) { self.nonce = nonce }
2121+2222+ public func clear() { nonce = nil }
2323+}
+46-18
Sources/CoreATProtocol/Networking.swift
···99import Crypto
1010import JWTKit
1111import NetworkingKit
1212+import OAuthenticator
1313+import os
1414+1515+private let networkingLog = Logger(subsystem: "com.sparrowtek.CoreATProtocol", category: "Networking")
12161317extension JSONDecoder {
1418 public static var atDecoder: JSONDecoder {
···5458 if let dpopKey = await APEnvironment.current.dpopPrivateKey,
5559 let keys = await APEnvironment.current.dpopKeys {
5660 // DPoP-bound token: use "DPoP" scheme + DPoP proof header
5757- request.setValue("DPoP \(accessToken)", forHTTPHeaderField: "Authorization")
5858-5959- if let proof = await generateDPoPProof(for: request, accessToken: accessToken, privateKey: dpopKey, keys: keys) {
6161+ do {
6262+ let proof = try await generateDPoPProof(for: request, accessToken: accessToken, privateKey: dpopKey, keys: keys)
6363+ request.setValue("DPoP \(accessToken)", forHTTPHeaderField: "Authorization")
6064 request.setValue(proof, forHTTPHeaderField: "DPoP")
6565+ } catch {
6666+ // DPoP signing failed. Do NOT attach the DPoP-bound access token
6767+ // without a proof — that would send a malformed authenticated
6868+ // request and waste a round trip. Clearing the header causes
6969+ // the networking layer to fail with an unauthenticated error,
7070+ // which surfaces a useful signal to callers.
7171+ networkingLog.error("DPoP proof generation failed: \(error.localizedDescription, privacy: .public)")
7272+ request.setValue(nil, forHTTPHeaderField: "Authorization")
7373+ request.setValue(nil, forHTTPHeaderField: "DPoP")
6174 }
6275 } else {
6376 // Standard Bearer token
···7083 accessToken: String,
7184 privateKey: ES256PrivateKey,
7285 keys: JWTKeyCollection
7373- ) async -> String? {
8686+ ) async throws -> String {
7487 guard let method = request.httpMethod,
7575- let url = request.url else { return nil }
8888+ let url = request.url else {
8989+ throw AtError.message(ErrorMessage(error: "DPoPProofInvalidRequest", message: "Request is missing method or URL"))
9090+ }
76917792 // Strip query and fragment per DPoP spec
7893 var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
···8095 components?.fragment = nil
8196 let htu = components?.url?.absoluteString ?? url.absoluteString
82978383- let nonce = await APEnvironment.current.dpopNonce
9898+ // Read the nonce at proof-generation time so a concurrent update
9999+ // between intercept() and sign() is observed on the next retry.
100100+ let nonce = await APEnvironment.current.dpopNonceStore.get()
8410185102 // ath: base64url-encoded SHA-256 hash of the access token (RFC 9449 §4.2)
86103 let hash = SHA256.hash(data: Data(accessToken.utf8))
···120137 ]
121138 }
122139123123- return try? await keys.sign(payload, header: header)
140140+ return try await keys.sign(payload, header: header)
124141 }
125142126143 public func didReceiveErrorResponse(_ response: HTTPURLResponse) async {
127127- let nonce = response.value(forHTTPHeaderField: "DPoP-Nonce")
144144+ let headerNonce = response.value(forHTTPHeaderField: "DPoP-Nonce")
128145 ?? response.value(forHTTPHeaderField: "dpop-nonce")
129129- if let nonce {
130130- await storeDPoPNonce(nonce)
146146+ if let headerNonce {
147147+ await APEnvironment.current.dpopNonceStore.update(headerNonce)
148148+ lastErrorHadNonceHeader = true
149149+ } else {
150150+ lastErrorHadNonceHeader = false
131151 }
132152 }
133153134134- @APActor
135135- private func storeDPoPNonce(_ nonce: String) {
136136- APEnvironment.current.dpopNonce = nonce
137137- }
154154+ private var lastErrorHadNonceHeader: Bool = false
138155139156 public func shouldRetry(error: Error, attempts: Int) async throws -> Bool {
140157 guard attempts <= 2 else { return false }
···196213 data = nil
197214 }
198215199199- guard let data,
200200- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
201201- let errorType = json["error"] as? String else { return false }
202202- return errorType == "use_dpop_nonce"
216216+ if let data {
217217+ do {
218218+ let oauthError = try JSONDecoder().decode(OAuthErrorResponse.self, from: data)
219219+ if oauthError.error == "use_dpop_nonce" { return true }
220220+ // Body decoded but wasn't a nonce challenge.
221221+ return false
222222+ } catch {
223223+ let body = String(data: data, encoding: .utf8) ?? "<non-utf8>"
224224+ networkingLog.debug("Failed to decode OAuth error body for nonce check: \(error.localizedDescription, privacy: .public) — body: \(body, privacy: .public)")
225225+ }
226226+ }
227227+228228+ // Fallback: a DPoP-Nonce header on an error response is a strong
229229+ // nonce-challenge signal even when the body is absent or malformed.
230230+ return lastErrorHadNonceHeader
203231 }
204232}
205233