this repo has no description
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

begin updating auth

+751 -141
+7
Sources/CoreATProtocol/APEnvironment.swift
··· 25 25 public var tokenRefreshHandler: (@Sendable () async throws -> Bool)? 26 26 public var dpopPrivateKey: ES256PrivateKey? 27 27 public var dpopKeys: JWTKeyCollection? 28 + /// Per-session signer that owns the DPoP key and the per-origin nonce 29 + /// cache (RFC 9449). Populated by ``setDPoPPrivateKey(pem:)``; production 30 + /// signing paths read from this property rather than the legacy 31 + /// ``dpopPrivateKey`` / ``dpopKeys`` / ``dpopNonceStore`` fields, which 32 + /// are retained for source compatibility until the next major release. 33 + public var dpopProofSigner: DPoPProofSigner? 28 34 public let dpopNonceStore = DPoPNonceStore() 29 35 public let clockSkewStore = ClockSkewStore() 30 36 public let routerDelegate = APRouterDelegate() ··· 44 50 tokenRefreshHandler = nil 45 51 dpopPrivateKey = nil 46 52 dpopKeys = nil 53 + dpopProofSigner = nil 47 54 await dpopNonceStore.clear() 48 55 await clockSkewStore.update(offset: 0) 49 56 }
+11 -1
Sources/CoreATProtocol/CoreATProtocol.swift
··· 57 57 guard let pem, !pem.isEmpty else { 58 58 ATProtoSession.shared.dpopPrivateKey = nil 59 59 ATProtoSession.shared.dpopKeys = nil 60 + ATProtoSession.shared.dpopProofSigner = nil 60 61 return 61 62 } 62 63 63 64 let privateKey = try ES256PrivateKey(pem: pem) 65 + let signer = await DPoPProofSigner( 66 + privateKey: privateKey, 67 + clockSkew: ATProtoSession.shared.clockSkewStore 68 + ) 69 + ATProtoSession.shared.dpopProofSigner = signer 70 + 71 + // Backward-compat: keep the legacy fields populated until they are 72 + // removed in a future major release. Production signing paths read from 73 + // ``dpopProofSigner``; these are only here so external callers that read 74 + // the public fields keep observing a non-nil value. 64 75 let keys = JWTKeyCollection() 65 76 await keys.add(ecdsa: privateKey) 66 - 67 77 ATProtoSession.shared.dpopPrivateKey = privateKey 68 78 ATProtoSession.shared.dpopKeys = keys 69 79 }
+14 -70
Sources/CoreATProtocol/Networking.swift
··· 6 6 // 7 7 8 8 import Foundation 9 - import Crypto 10 - import JWTKit 11 9 import NetworkingKit 12 10 import OAuthenticator 13 11 import os ··· 55 53 public func intercept(_ request: inout URLRequest) async { 56 54 guard let accessToken = await ATProtoSession.shared.accessToken else { return } 57 55 58 - if let dpopKey = await ATProtoSession.shared.dpopPrivateKey, 59 - let keys = await ATProtoSession.shared.dpopKeys { 60 - // DPoP-bound token: use "DPoP" scheme + DPoP proof header 56 + if let signer = await ATProtoSession.shared.dpopProofSigner, 57 + let url = request.url, 58 + let method = request.httpMethod { 61 59 do { 62 - let proof = try await generateDPoPProof(for: request, accessToken: accessToken, privateKey: dpopKey, keys: keys) 60 + let proof = try await signer.sign(.init( 61 + httpMethod: method, 62 + url: url, 63 + accessToken: accessToken 64 + )) 63 65 request.setValue("DPoP \(accessToken)", forHTTPHeaderField: "Authorization") 64 66 request.setValue(proof, forHTTPHeaderField: "DPoP") 65 67 } catch { ··· 78 80 } 79 81 } 80 82 81 - private func generateDPoPProof( 82 - for request: URLRequest, 83 - accessToken: String, 84 - privateKey: ES256PrivateKey, 85 - keys: JWTKeyCollection 86 - ) async throws -> String { 87 - guard let method = request.httpMethod, 88 - let url = request.url else { 89 - throw AtError.message(ErrorMessage(error: "DPoPProofInvalidRequest", message: "Request is missing method or URL")) 90 - } 91 - 92 - // Strip query and fragment per DPoP spec 93 - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) 94 - components?.query = nil 95 - components?.fragment = nil 96 - let htu = components?.url?.absoluteString ?? url.absoluteString 97 - 98 - // Read the nonce at proof-generation time so a concurrent update 99 - // between intercept() and sign() is observed on the next retry. 100 - let nonce = await ATProtoSession.shared.dpopNonceStore.get() 101 - let issuedAt = await ATProtoSession.shared.clockSkewStore.serverAdjustedNow() 102 - 103 - // ath: base64url-encoded SHA-256 hash of the access token (RFC 9449 §4.2) 104 - let hash = SHA256.hash(data: Data(accessToken.utf8)) 105 - let ath = Data(hash).base64URLEncodedString() 106 - 107 - let payload = DPoPProofPayload( 108 - htm: method, 109 - htu: htu, 110 - iat: .init(value: issuedAt), 111 - jti: .init(value: UUID().uuidString), 112 - nonce: nonce, 113 - ath: ath 114 - ) 115 - 116 - var header = JWTHeader() 117 - header.typ = "dpop+jwt" 118 - header.alg = "ES256" 119 - 120 - if let keyParams = privateKey.parameters { 121 - header.jwk = [ 122 - "kty": .string("EC"), 123 - "crv": .string("P-256"), 124 - "x": .string(keyParams.x.base64URLEncoded()), 125 - "y": .string(keyParams.y.base64URLEncoded()), 126 - ] 127 - } 128 - 129 - return try await keys.sign(payload, header: header) 130 - } 131 - 132 83 public func didReceiveErrorResponse(_ response: HTTPURLResponse) async { 133 84 // Record clock skew from any response that carries a Date header. 134 85 // DPoP rejections often correlate with skew, so harvest this eagerly. ··· 140 91 let headerNonce = response.value(forHTTPHeaderField: "DPoP-Nonce") 141 92 ?? response.value(forHTTPHeaderField: "dpop-nonce") 142 93 if let headerNonce { 94 + if let url = response.url, 95 + let signer = await ATProtoSession.shared.dpopProofSigner { 96 + await signer.cacheNonce(headerNonce, from: url) 97 + } 98 + // Backward-compat: keep the single-slot store updated for 99 + // external readers of the public ``dpopNonceStore`` accessor. 143 100 await ATProtoSession.shared.dpopNonceStore.update(headerNonce) 144 101 lastErrorHadNonceHeader = true 145 102 } else { ··· 215 172 // nonce-challenge signal even when the body is absent or malformed. 216 173 return lastErrorHadNonceHeader 217 174 } 218 - } 219 - 220 - // MARK: - DPoP Proof Payload 221 - 222 - private struct DPoPProofPayload: JWTPayload { 223 - let htm: String 224 - let htu: String 225 - let iat: IssuedAtClaim 226 - let jti: IDClaim 227 - let nonce: String? 228 - let ath: String? 229 - 230 - func verify(using key: some JWTAlgorithm) throws {} 231 175 } 232 176 233 177 private enum DateParser {
+28 -70
Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift
··· 157 157 private let dpopRequestActor = DPoPRequestActor() 158 158 private var hasPersistedKey: Bool 159 159 160 - // JWT signing keys (pattern from AtProtocol) 161 - private var keys: JWTKeyCollection 162 160 private var privateKey: ES256PrivateKey 161 + private var proofSigner: DPoPProofSigner 163 162 164 163 private struct AuthenticationAttemptResult { 165 164 let login: Login ··· 171 170 self.storage = storage 172 171 self.identityResolver = IdentityResolver() 173 172 174 - // Initialize JWT keys (from AtProto.swift lines 19-23) 175 173 if let storedKeyData = try? await storage.retrievePrivateKey(), 176 174 let pem = String(data: storedKeyData, encoding: .utf8), 177 175 let restoredKey = try? ES256PrivateKey(pem: pem) { ··· 181 179 self.privateKey = ES256PrivateKey() 182 180 self.hasPersistedKey = false 183 181 } 184 - self.keys = JWTKeyCollection() 185 - await self.keys.add(ecdsa: privateKey) 182 + self.proofSigner = await DPoPProofSigner( 183 + privateKey: privateKey, 184 + clockSkew: ATProtoSession.shared.clockSkewStore 185 + ) 186 186 } 187 187 188 188 /// Initialize with existing private key (for session restoration) ··· 191 191 self.storage = storage 192 192 self.identityResolver = IdentityResolver() 193 193 194 - // Restore existing key 195 194 self.privateKey = try ES256PrivateKey(pem: privateKeyPEM) 196 195 self.hasPersistedKey = true 197 - self.keys = JWTKeyCollection() 198 - await self.keys.add(ecdsa: privateKey) 196 + self.proofSigner = await DPoPProofSigner( 197 + privateKey: privateKey, 198 + clockSkew: ATProtoSession.shared.clockSkewStore 199 + ) 199 200 } 200 201 201 202 /// Authenticate user by handle ··· 523 524 privateKey.pemRepresentation 524 525 } 525 526 526 - // MARK: - Private (from AtProto.swift lines 60-72) 527 + // MARK: - Private 527 528 528 529 private func generateJWT(params: DPoPSigner.JWTParameters) async throws -> String { 529 - // Strip query params and fragments from htu per DPoP spec 530 - let htu = stripQueryAndFragment(from: params.requestEndpoint) 531 - 532 - let issuedAt = await ATProtoSession.shared.clockSkewStore.serverAdjustedNow() 533 - 534 - let payload = DPoPPayload( 535 - htm: params.httpMethod, 536 - htu: htu, 537 - iat: .init(value: issuedAt), 538 - jti: .init(value: UUID().uuidString), 539 - nonce: params.nonce 540 - ) 541 - 542 - // DPoP requires typ="dpop+jwt", alg="ES256", and the public key in jwk header 543 - var header = JWTHeader() 544 - header.typ = "dpop+jwt" 545 - header.alg = "ES256" 546 - 547 - // Get public key parameters and convert to base64url for JWK 548 - if let keyParams = privateKey.parameters { 549 - header.jwk = [ 550 - "kty": .string("EC"), 551 - "crv": .string("P-256"), 552 - "x": .string(keyParams.x.base64URLEncoded()), 553 - "y": .string(keyParams.y.base64URLEncoded()) 554 - ] 555 - } 556 - 557 - return try await self.keys.sign(payload, header: header) 558 - } 559 - 560 - /// Strip query string and fragment from URL per DPoP spec 561 - private func stripQueryAndFragment(from url: String) -> String { 562 - let fragmentIndex = url.firstIndex(of: "#").map { url.distance(from: url.startIndex, to: $0) } ?? -1 563 - let queryIndex = url.firstIndex(of: "?").map { url.distance(from: url.startIndex, to: $0) } ?? -1 564 - 565 - let end: Int 566 - if fragmentIndex == -1 { 567 - end = queryIndex 568 - } else if queryIndex == -1 { 569 - end = fragmentIndex 570 - } else { 571 - end = min(fragmentIndex, queryIndex) 530 + guard let url = URL(string: params.requestEndpoint) else { 531 + throw ATProtoOAuthError.malformedServerMetadata( 532 + field: "request_endpoint", 533 + value: params.requestEndpoint 534 + ) 572 535 } 573 - 574 - return end == -1 ? url : String(url.prefix(end)) 536 + // OAuthenticator's `DPoPSigner` already tracks the auth-server nonce 537 + // for this exchange — pass it through so we don't consult the 538 + // proof signer's per-origin cache (which is owned by the XRPC layer). 539 + return try await proofSigner.sign(.init( 540 + httpMethod: params.httpMethod, 541 + url: url, 542 + accessToken: nil, 543 + explicitNonce: params.nonce 544 + )) 575 545 } 576 546 577 547 private func stripScheme(from url: String) -> String { ··· 594 564 595 565 private func resetDPoPKey() async throws { 596 566 privateKey = ES256PrivateKey() 597 - keys = JWTKeyCollection() 598 - await keys.add(ecdsa: privateKey) 567 + proofSigner = await DPoPProofSigner( 568 + privateKey: privateKey, 569 + clockSkew: ATProtoSession.shared.clockSkewStore 570 + ) 599 571 hasPersistedKey = false 600 572 try await persistPrivateKey() 601 573 } ··· 905 877 } 906 878 let port = url.port.map { ":\($0)" } ?? "" 907 879 return "\(scheme)://\(host)\(port)" 908 - } 909 - } 910 - 911 - // MARK: - DPoP Payload (from AtProto.swift lines 88-98) 912 - 913 - private struct DPoPPayload: JWTPayload { 914 - let htm: String 915 - let htu: String 916 - let iat: IssuedAtClaim 917 - let jti: IDClaim 918 - let nonce: String? 919 - 920 - func verify(using key: some JWTAlgorithm) throws { 921 - // No additional verification needed for DPoP 922 880 } 923 881 } 924 882
+149
Sources/CoreATProtocol/OAuth/DPoPProofSigner.swift
··· 1 + // 2 + // DPoPProofSigner.swift 3 + // CoreATProtocol 4 + // 5 + 6 + import Foundation 7 + import Crypto 8 + import JWTKit 9 + 10 + /// Owns the DPoP signing key, the per-origin nonce cache, and proof-JWT 11 + /// generation for a single AT Protocol session. 12 + /// 13 + /// One `DPoPProofSigner` per logical session. Nonces are keyed by origin 14 + /// (`scheme://host[:port]`) per RFC 9449 — the auth server and the PDS have 15 + /// independent nonce streams, and using the wrong one causes a spurious 16 + /// `use_dpop_nonce` challenge on the next request. 17 + /// 18 + /// The type name avoids a collision with `OAuthenticator.DPoPSigner`, which 19 + /// fills a different role (a per-flow nonce holder used by `Authenticator`). 20 + public actor DPoPProofSigner { 21 + public struct ProofParameters: Sendable { 22 + public let httpMethod: String 23 + public let url: URL 24 + /// Access token to bind via the `ath` claim (RFC 9449 §4.2). Pass 25 + /// `nil` for unauthenticated requests like PAR or token exchange. 26 + public let accessToken: String? 27 + /// When non-nil, overrides any cached nonce for this URL's origin. 28 + /// Used when an external party (e.g. `OAuthenticator.DPoPSigner`) is 29 + /// already tracking the DPoP nonce for the exchange and the cache 30 + /// should not be consulted. 31 + public let explicitNonce: String? 32 + 33 + public init( 34 + httpMethod: String, 35 + url: URL, 36 + accessToken: String? = nil, 37 + explicitNonce: String? = nil 38 + ) { 39 + self.httpMethod = httpMethod 40 + self.url = url 41 + self.accessToken = accessToken 42 + self.explicitNonce = explicitNonce 43 + } 44 + } 45 + 46 + private var privateKey: ES256PrivateKey 47 + private var keys: JWTKeyCollection 48 + private let clockSkew: ClockSkewStore 49 + private var noncesByOrigin: [String: String] = [:] 50 + private let nonceCacheLimit = 25 51 + 52 + public init(privateKey: ES256PrivateKey, clockSkew: ClockSkewStore) async { 53 + self.privateKey = privateKey 54 + let keys = JWTKeyCollection() 55 + await keys.add(ecdsa: privateKey) 56 + self.keys = keys 57 + self.clockSkew = clockSkew 58 + } 59 + 60 + public var pemRepresentation: String { privateKey.pemRepresentation } 61 + 62 + public func sign(_ params: ProofParameters) async throws -> String { 63 + let htu = Self.canonicalHTU(params.url) 64 + let nonce: String? 65 + if let explicit = params.explicitNonce { 66 + nonce = explicit 67 + } else if let origin = Self.origin(for: params.url) { 68 + nonce = noncesByOrigin[origin] 69 + } else { 70 + nonce = nil 71 + } 72 + let issuedAt = await clockSkew.serverAdjustedNow() 73 + let ath = params.accessToken.map(Self.athClaim) 74 + 75 + var header = JWTHeader() 76 + header.typ = "dpop+jwt" 77 + header.alg = "ES256" 78 + if let keyParams = privateKey.parameters { 79 + header.jwk = [ 80 + "kty": .string("EC"), 81 + "crv": .string("P-256"), 82 + "x": .string(keyParams.x.base64URLEncoded()), 83 + "y": .string(keyParams.y.base64URLEncoded()), 84 + ] 85 + } 86 + 87 + let payload = DPoPProofPayload( 88 + htm: params.httpMethod, 89 + htu: htu, 90 + iat: .init(value: issuedAt), 91 + jti: .init(value: UUID().uuidString), 92 + nonce: nonce, 93 + ath: ath 94 + ) 95 + return try await keys.sign(payload, header: header) 96 + } 97 + 98 + /// Records `nonce` against the origin of `url`. When the cache is full and 99 + /// the origin isn't already tracked, an arbitrary entry is evicted to keep 100 + /// growth bounded — a true LRU is overkill since the realistic origin 101 + /// count is two (auth server and PDS). 102 + public func cacheNonce(_ nonce: String, from url: URL) { 103 + guard let origin = Self.origin(for: url) else { return } 104 + if noncesByOrigin.count >= nonceCacheLimit, noncesByOrigin[origin] == nil { 105 + if let key = noncesByOrigin.keys.first { 106 + noncesByOrigin.removeValue(forKey: key) 107 + } 108 + } 109 + noncesByOrigin[origin] = nonce 110 + } 111 + 112 + public func clearNonces() { 113 + noncesByOrigin.removeAll() 114 + } 115 + 116 + // MARK: - Helpers 117 + 118 + static func canonicalHTU(_ url: URL) -> String { 119 + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) 120 + components?.query = nil 121 + components?.fragment = nil 122 + return components?.url?.absoluteString ?? url.absoluteString 123 + } 124 + 125 + static func origin(for url: URL) -> String? { 126 + guard let scheme = url.scheme?.lowercased(), 127 + let host = url.host?.lowercased() else { 128 + return nil 129 + } 130 + let port = url.port.map { ":\($0)" } ?? "" 131 + return "\(scheme)://\(host)\(port)" 132 + } 133 + 134 + static func athClaim(for accessToken: String) -> String { 135 + let hash = SHA256.hash(data: Data(accessToken.utf8)) 136 + return Data(hash).base64URLEncodedString() 137 + } 138 + } 139 + 140 + private struct DPoPProofPayload: JWTPayload { 141 + let htm: String 142 + let htu: String 143 + let iat: IssuedAtClaim 144 + let jti: IDClaim 145 + let nonce: String? 146 + let ath: String? 147 + 148 + func verify(using key: some JWTAlgorithm) throws {} 149 + }
+200
Tests/CoreATProtocolTests/DPoPProofSignerTests.swift
··· 1 + import Foundation 2 + import Testing 3 + import JWTKit 4 + @testable import CoreATProtocol 5 + 6 + @Suite("DPoPProofSigner") 7 + struct DPoPProofSignerTests { 8 + @Test("Sign emits a dpop+jwt header with embedded JWK") 9 + func dpopJwtType() async throws { 10 + let signer = await makeSigner() 11 + let url = try #require(URL(string: "https://pds.example/xrpc/foo")) 12 + 13 + let jwt = try await signer.sign(.init( 14 + httpMethod: "POST", 15 + url: url, 16 + accessToken: "tok" 17 + )) 18 + 19 + let header = try decodeJWTHeader(jwt) 20 + #expect(header["typ"] as? String == "dpop+jwt") 21 + #expect(header["alg"] as? String == "ES256") 22 + 23 + let jwk = try #require(header["jwk"] as? [String: Any]) 24 + #expect(jwk["kty"] as? String == "EC") 25 + #expect(jwk["crv"] as? String == "P-256") 26 + #expect((jwk["x"] as? String)?.isEmpty == false) 27 + #expect((jwk["y"] as? String)?.isEmpty == false) 28 + } 29 + 30 + @Test("htu strips query string and fragment") 31 + func htuStripsQueryAndFragment() async throws { 32 + let signer = await makeSigner() 33 + let url = try #require(URL(string: "https://pds.example/xrpc/foo?bar=baz#frag")) 34 + 35 + let jwt = try await signer.sign(.init(httpMethod: "POST", url: url)) 36 + 37 + let payload = try decodeJWTPayload(jwt) 38 + #expect(payload["htu"] as? String == "https://pds.example/xrpc/foo") 39 + #expect(payload["htm"] as? String == "POST") 40 + } 41 + 42 + @Test("ath claim is present only when an access token is supplied") 43 + func athPresentOnlyWhenAccessTokenProvided() async throws { 44 + let signer = await makeSigner() 45 + let url = try #require(URL(string: "https://pds.example/xrpc/foo")) 46 + 47 + let withToken = try await signer.sign(.init( 48 + httpMethod: "GET", 49 + url: url, 50 + accessToken: "abc" 51 + )) 52 + #expect(try decodeJWTPayload(withToken)["ath"] as? String != nil) 53 + 54 + let withoutToken = try await signer.sign(.init(httpMethod: "GET", url: url)) 55 + #expect(try decodeJWTPayload(withoutToken)["ath"] == nil) 56 + } 57 + 58 + @Test("Nonce cache is keyed per origin") 59 + func nonceCachedPerOrigin() async throws { 60 + let signer = await makeSigner() 61 + let pds = try #require(URL(string: "https://pds.example/xrpc/foo")) 62 + let auth = try #require(URL(string: "https://auth.example/oauth/token")) 63 + 64 + await signer.cacheNonce("pds-nonce", from: pds) 65 + await signer.cacheNonce("auth-nonce", from: auth) 66 + 67 + let pdsJWT = try await signer.sign(.init(httpMethod: "POST", url: pds)) 68 + let authJWT = try await signer.sign(.init(httpMethod: "POST", url: auth)) 69 + 70 + #expect(try decodeJWTPayload(pdsJWT)["nonce"] as? String == "pds-nonce") 71 + #expect(try decodeJWTPayload(authJWT)["nonce"] as? String == "auth-nonce") 72 + } 73 + 74 + @Test("Nonce cache stays bounded under heavy origin churn") 75 + func nonceCacheBounded() async throws { 76 + let signer = await makeSigner() 77 + let totalInsertions = 30 78 + let cacheLimit = 25 79 + 80 + for i in 0..<totalInsertions { 81 + let url = try #require(URL(string: "https://host\(i).example/foo")) 82 + await signer.cacheNonce("nonce-\(i)", from: url) 83 + } 84 + 85 + // Eviction policy is bounded growth, not LRU — we cannot predict 86 + // *which* origins were evicted, but the cache size is invariant. 87 + var cachedCount = 0 88 + for i in 0..<totalInsertions { 89 + let url = try #require(URL(string: "https://host\(i).example/foo")) 90 + let jwt = try await signer.sign(.init(httpMethod: "POST", url: url)) 91 + if try decodeJWTPayload(jwt)["nonce"] != nil { 92 + cachedCount += 1 93 + } 94 + } 95 + #expect(cachedCount == cacheLimit) 96 + 97 + // The most recent insertion is always retained — its eviction step 98 + // runs before its own insertion, so it cannot evict itself. 99 + let recent = try #require(URL(string: "https://host\(totalInsertions - 1).example/foo")) 100 + let recentJWT = try await signer.sign(.init(httpMethod: "POST", url: recent)) 101 + #expect(try decodeJWTPayload(recentJWT)["nonce"] as? String == "nonce-\(totalInsertions - 1)") 102 + } 103 + 104 + @Test("iat respects observed clock skew") 105 + func iatRespectsClockSkew() async throws { 106 + let clock = ClockSkewStore(offset: 600) 107 + let signer = await DPoPProofSigner( 108 + privateKey: ES256PrivateKey(), 109 + clockSkew: clock 110 + ) 111 + let url = try #require(URL(string: "https://pds.example/foo")) 112 + 113 + let beforeSign = Date.now 114 + let jwt = try await signer.sign(.init(httpMethod: "GET", url: url)) 115 + let afterSign = Date.now 116 + 117 + let payload = try decodeJWTPayload(jwt) 118 + // JWT-Kit encodes Date as a fractional number of seconds, not an 119 + // integer. RFC 7519 §2 (NumericDate) permits this. 120 + let iat = try #require(payload["iat"] as? Double) 121 + 122 + let lowerBound = beforeSign.timeIntervalSince1970 + 600 123 + let upperBound = afterSign.timeIntervalSince1970 + 600 + 1 124 + #expect(iat >= lowerBound) 125 + #expect(iat <= upperBound) 126 + } 127 + 128 + @Test("Explicit nonce overrides any cached value for the URL's origin") 129 + func explicitNonceOverride() async throws { 130 + let signer = await makeSigner() 131 + let url = try #require(URL(string: "https://host.example/foo")) 132 + 133 + await signer.cacheNonce("cached", from: url) 134 + 135 + let withExplicit = try await signer.sign(.init( 136 + httpMethod: "POST", 137 + url: url, 138 + explicitNonce: "explicit" 139 + )) 140 + #expect(try decodeJWTPayload(withExplicit)["nonce"] as? String == "explicit") 141 + 142 + let withoutExplicit = try await signer.sign(.init(httpMethod: "POST", url: url)) 143 + #expect(try decodeJWTPayload(withoutExplicit)["nonce"] as? String == "cached") 144 + } 145 + 146 + @Test("clearNonces drops all cached values") 147 + func clearNoncesEmptiesCache() async throws { 148 + let signer = await makeSigner() 149 + let url = try #require(URL(string: "https://host.example/foo")) 150 + 151 + await signer.cacheNonce("first", from: url) 152 + await signer.clearNonces() 153 + 154 + let jwt = try await signer.sign(.init(httpMethod: "GET", url: url)) 155 + #expect(try decodeJWTPayload(jwt)["nonce"] == nil) 156 + } 157 + 158 + private func makeSigner() async -> DPoPProofSigner { 159 + await DPoPProofSigner( 160 + privateKey: ES256PrivateKey(), 161 + clockSkew: ClockSkewStore() 162 + ) 163 + } 164 + } 165 + 166 + // MARK: - JWT decoding helpers 167 + 168 + private func decodeJWTHeader(_ jwt: String) throws -> [String: Any] { 169 + try decodeJWTSection(jwt, index: 0) 170 + } 171 + 172 + private func decodeJWTPayload(_ jwt: String) throws -> [String: Any] { 173 + try decodeJWTSection(jwt, index: 1) 174 + } 175 + 176 + private func decodeJWTSection(_ jwt: String, index: Int) throws -> [String: Any] { 177 + let parts = jwt.split(separator: ".", omittingEmptySubsequences: false) 178 + guard parts.count == 3, index < parts.count else { 179 + throw JWTTestError.malformedJWT 180 + } 181 + let data = try base64URLDecode(String(parts[index])) 182 + let object = try JSONSerialization.jsonObject(with: data) 183 + return object as? [String: Any] ?? [:] 184 + } 185 + 186 + private func base64URLDecode(_ value: String) throws -> Data { 187 + var base64 = value 188 + .replacingOccurrences(of: "-", with: "+") 189 + .replacingOccurrences(of: "_", with: "/") 190 + let padding = (4 - base64.count % 4) % 4 191 + base64 += String(repeating: "=", count: padding) 192 + guard let data = Data(base64Encoded: base64) else { 193 + throw JWTTestError.malformedJWT 194 + } 195 + return data 196 + } 197 + 198 + private enum JWTTestError: Error { 199 + case malformedJWT 200 + }
+1
Tests/CoreATProtocolTests/OAuthTests.swift
··· 444 444 #expect(keyPEM == restoredKeyPEM) 445 445 } 446 446 } 447 +
+341
update_auth.md
··· 1 + # OAuth update plan for CoreATProtocol 2 + 3 + Goal: keep OAuthenticator as the OAuth 2.1 transport, but tighten the AT-Proto-specific layer above it. Borrow the few ideas AtprotoOAuth (MIT, germ-network) does well that genuinely improve correctness, testability, or readability. No library swap. Each step leaves the package building and the existing test suite green. 4 + 5 + ## Status (2026-04-29) 6 + 7 + - **Steps 1–2 are landed.** Per-origin DPoP nonce caching is live. CoreATProtocol's 66 tests pass; bskyKit and EffemKit rebuild cleanly against the local checkout. 8 + - **Steps 3–5 are next.** They're independent of each other and any pair of them is a reasonable single-session chunk. 9 + - **Step 6 remains gated** behind a SemVer-major bump. 10 + 11 + See "Notes from the Steps 1+2 implementation" near the bottom for the deviations from the original plan. 12 + 13 + ## Inventory of what we have today 14 + 15 + Files (in `Sources/CoreATProtocol/`): 16 + - `OAuth/ATProtoOAuth.swift` — 1016 lines. Authorize / refresh / DPoP JWT generation / token persistence. Single class doing everything. 17 + - `OAuth/AuthProxy.swift` — 294 lines. `URLResponseProvider` interceptor that re-routes PAR + token requests through an auth proxy, with bypass on transport failure. 18 + - `OAuth/IdentityResolver.swift` — 366 lines. Handle ↔ DID ↔ PDS ↔ auth server resolution + bidirectional handle verification. 19 + - `Networking.swift` — `APRouterDelegate` that signs each authenticated XRPC request with a DPoP proof, watches for `use_dpop_nonce`, refreshes on 401/403, harvests clock skew. 20 + - `APEnvironment.swift` — `ATProtoSession.shared` singleton holding tokens + DPoP key + JWT key collection + nonce store + clock skew store. 21 + - `DPoPNonceStore.swift` — single-slot actor for the most recent server nonce. 22 + - `ClockSkewStore.swift` — observed offset between local and server clocks. 23 + - `TokenRefreshCoordinator.swift` — coalesces concurrent refresh attempts. 24 + - `Models/` — `Session`, `AtError`, etc. 25 + 26 + Consumers: 27 + - `bskyKit` re-exports `ATProtoOAuth` as `BskyOAuth` (via `@_exported import CoreATProtocol`). 28 + - `Atprosphere` calls `ATProtoOAuth(config:storage:)` with `clientAuthMethod: .privateKeyJWT` + `authProxyBaseURL`. 29 + - `effem` calls `ATProtoOAuth(config:storage:)` with `authProxyBaseURL` only (default `.none`). 30 + - `ATProtoCLI` does not use OAuth. 31 + 32 + Tests: 1012 lines across 9 files, including `OAuthTests.swift` (446 lines) covering proxy request encoding, key ID storage, DPoP key persistence, and identity resolution. 33 + 34 + ## What's worth borrowing from AtprotoOAuth 35 + 36 + 1. **Per-origin DPoP nonce cache.** AtprotoOAuth's `OAuth.DPoP.State.nonceCache` is keyed by origin (capped at 25 entries). RFC 9449 lets each server issue its own nonce; the auth server's nonce ≠ the PDS's nonce. Our single-slot store causes spurious `use_dpop_nonce` retries when the most recent nonce is from the wrong origin. 37 + 2. **Consolidated DPoP signer type.** AtprotoOAuth bundles `signingKey` + `nonceCache` + `decoder` into one value (`OAuth.DPoP.State`). We have these scattered across `ATProtoSession` (`dpopPrivateKey`, `dpopKeys`, `dpopNonceStore`) and re-thread them through `Networking.swift` and `ATProtoOAuth.swift`. A single `DPoPSigner` actor would hold the lot. 38 + 3. **`htu` canonicalization helper.** We strip query+fragment in two places (`Networking.swift` line 92-96 and `ATProtoOAuth.swift` line 561). One small free function. 39 + 4. **Pluggable resolver protocol.** Right now `IdentityResolver` is concrete. AtprotoOAuth has `Atproto.Resolver` as a protocol with a default `verifiedResolve` and a `FallbackResolver` that tries primary then secondary. Useful for tests (inject a fake resolver) and for adding Slingshot/community DoH fallback later. 40 + 5. **Token validator closure.** AtprotoOAuth pulls token validation (sub matches expected DID, issuer matches expected auth server, DPoP token type, scope contains "atproto") out of the loginProvider into a single throwing closure. Easier to unit test in isolation. We already do this work — just inline. 41 + 42 + What we should *not* borrow: 43 + - `AtprotoOAuthAgent`/`AuthPDSAgent` — couples to germ's XRPC abstraction. 44 + - `OAuth.SessionState.Archive` — our `Login`/`storage` callbacks already do the same job. 45 + - AtprotoTypes `Atproto.DID`/`Handle`/`DIDDocument` — we have our own types, no need to swap. 46 + - The `AsyncStream` save model — a callback closure (what we have) is simpler and equally correct. 47 + 48 + ## Plan 49 + 50 + Each step ends with `swift test` passing in `CoreATProtocol/`, then a build of `bskyKit`, Atprosphere, and effem against the local CoreATProtocol checkout. Treat the build of all three apps as the integration gate. 51 + 52 + ### Step 1 — Add `DPoPSigner` (the consolidated signer actor) — DONE 53 + 54 + **Why first:** the per-origin nonce fix lands here and is the highest-value correctness improvement. Doing it before the file split keeps later diffs small. 55 + 56 + Create `Sources/CoreATProtocol/OAuth/DPoPSigner.swift`: 57 + 58 + ```swift 59 + import Foundation 60 + import Crypto 61 + import JWTKit 62 + 63 + /// Owns the DPoP signing key, the per-origin nonce cache, and JWT generation. 64 + /// 65 + /// One `DPoPSigner` per logical session. Nonces are keyed by origin 66 + /// (`scheme://host[:port]`) per RFC 9449 — the auth server and the PDS have 67 + /// independent nonce streams, and using the wrong one causes a spurious 68 + /// `use_dpop_nonce` challenge on the next request. 69 + public actor DPoPSigner { 70 + public struct ProofParameters: Sendable { 71 + public let httpMethod: String 72 + public let url: URL 73 + /// Access token to bind via `ath`. Pass `nil` for unauthenticated 74 + /// requests (PAR, token exchange). 75 + public let accessToken: String? 76 + } 77 + 78 + private let privateKey: ES256PrivateKey 79 + private let keys: JWTKeyCollection 80 + private var noncesByOrigin: [String: String] = [:] 81 + private let nonceCacheLimit = 25 82 + private let clockSkew: ClockSkewStore 83 + 84 + public init(privateKey: ES256PrivateKey, clockSkew: ClockSkewStore) async { 85 + self.privateKey = privateKey 86 + let keys = JWTKeyCollection() 87 + await keys.add(ecdsa: privateKey) 88 + self.keys = keys 89 + self.clockSkew = clockSkew 90 + } 91 + 92 + public convenience init(clockSkew: ClockSkewStore) async { 93 + await self.init(privateKey: ES256PrivateKey(), clockSkew: clockSkew) 94 + } 95 + 96 + public var pemRepresentation: String { privateKey.pemRepresentation } 97 + 98 + public func sign(_ params: ProofParameters) async throws -> String { 99 + let htu = Self.canonicalHTU(params.url) 100 + let origin = Self.origin(for: params.url) 101 + let nonce = origin.flatMap { noncesByOrigin[$0] } 102 + let issuedAt = await clockSkew.serverAdjustedNow() 103 + let ath = params.accessToken.map(Self.athClaim) 104 + 105 + var header = JWTHeader() 106 + header.typ = "dpop+jwt" 107 + header.alg = "ES256" 108 + if let p = privateKey.parameters { 109 + header.jwk = [ 110 + "kty": .string("EC"), "crv": .string("P-256"), 111 + "x": .string(p.x.base64URLEncoded()), 112 + "y": .string(p.y.base64URLEncoded()), 113 + ] 114 + } 115 + 116 + let payload = DPoPProofPayload( 117 + htm: params.httpMethod, htu: htu, 118 + iat: .init(value: issuedAt), 119 + jti: .init(value: UUID().uuidString), 120 + nonce: nonce, ath: ath 121 + ) 122 + return try await keys.sign(payload, header: header) 123 + } 124 + 125 + /// Records a nonce against the origin of `url`, evicting the oldest entry 126 + /// if the cache exceeds its limit. 127 + public func cacheNonce(_ nonce: String, from url: URL) { 128 + guard let origin = Self.origin(for: url) else { return } 129 + if noncesByOrigin.count >= nonceCacheLimit, noncesByOrigin[origin] == nil { 130 + // Drop one arbitrary entry. A true LRU is overkill — bounded growth is the goal. 131 + if let key = noncesByOrigin.keys.first { noncesByOrigin.removeValue(forKey: key) } 132 + } 133 + noncesByOrigin[origin] = nonce 134 + } 135 + 136 + public func clearNonces() { noncesByOrigin.removeAll() } 137 + 138 + // MARK: - Helpers 139 + 140 + static func canonicalHTU(_ url: URL) -> String { 141 + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) 142 + components?.query = nil 143 + components?.fragment = nil 144 + return components?.url?.absoluteString ?? url.absoluteString 145 + } 146 + 147 + static func origin(for url: URL) -> String? { 148 + guard let scheme = url.scheme?.lowercased(), 149 + let host = url.host?.lowercased() else { return nil } 150 + let port = url.port.map { ":\($0)" } ?? "" 151 + return "\(scheme)://\(host)\(port)" 152 + } 153 + 154 + static func athClaim(for accessToken: String) -> String { 155 + let hash = SHA256.hash(data: Data(accessToken.utf8)) 156 + return Data(hash).base64URLEncodedString() 157 + } 158 + } 159 + 160 + private struct DPoPProofPayload: JWTPayload { 161 + let htm: String 162 + let htu: String 163 + let iat: IssuedAtClaim 164 + let jti: IDClaim 165 + let nonce: String? 166 + let ath: String? 167 + func verify(using key: some JWTAlgorithm) throws {} 168 + } 169 + ``` 170 + 171 + Tests to add (Swift Testing): 172 + - `DPoPSignerTests.signEmitsDpopJwtType` — verify header `typ=dpop+jwt`, `alg=ES256`, JWK has `kty/crv/x/y`. 173 + - `DPoPSignerTests.htuStripsQueryAndFragment` — `https://x/y?z#a` → `https://x/y`. 174 + - `DPoPSignerTests.athPresentOnlyWhenAccessTokenProvided` — round-trip JWT, decode payload, check `ath`. 175 + - `DPoPSignerTests.nonceCachedPerOrigin` — cache nonces for two origins, verify each origin gets its own nonce in the next signed proof. 176 + - `DPoPSignerTests.nonceCacheBounded` — push 30 origins, assert ≤ 25 entries. 177 + - `DPoPSignerTests.iatRespectsClockSkew` — set `ClockSkewStore` offset to +600, sign, decode `iat`, assert ≈ now + 600. 178 + 179 + Don't wire it into `Networking.swift` or `ATProtoOAuth.swift` yet — that's Step 2. 180 + 181 + ### Step 2 — Replace ad-hoc DPoP signing in `Networking.swift` and `ATProtoOAuth.swift` — DONE 182 + 183 + `APRouterDelegate.intercept` currently reads `dpopPrivateKey`, `dpopKeys`, `dpopNonceStore` from `ATProtoSession.shared` and builds the proof inline (Networking.swift:55-129). Replace with a call to a `DPoPSigner` held on the session. 184 + 185 + Changes: 186 + - Add `var dpopSigner: DPoPSigner?` to `ATProtoSession`. 187 + - Update `setDPoPPrivateKey(pem:)` in `CoreATProtocol.swift` to instantiate `DPoPSigner` instead of populating `dpopPrivateKey` + `dpopKeys`. 188 + - Replace `APRouterDelegate.generateDPoPProof` body with `try await signer.sign(.init(httpMethod: method, url: url, accessToken: accessToken))`. 189 + - Replace `APRouterDelegate.didReceiveErrorResponse`'s `dpopNonceStore.update(headerNonce)` with `signer.cacheNonce(headerNonce, from: response.url ?? request.url)`. The response URL is the right origin to key on. 190 + - In `ATProtoOAuth.generateJWT` (the auth-server-side signer at line 528), build a `DPoPSigner.ProofParameters` from `params.requestEndpoint` + `params.httpMethod` + `nil` access token. Delete the local `stripQueryAndFragment` helper (line 561). 191 + - Keep `DPoPNonceStore` for now — it backs the deprecated `dpopNonceStore` accessor on the session. Mark it `@available(*, deprecated, message: "Use DPoPSigner.cacheNonce instead.")` and migrate callers in Step 6. 192 + - Keep the existing `dpopPrivateKey` / `dpopKeys` accessors on `ATProtoSession`, but mark them deprecated. They're public, so removing them is a major-version change. 193 + 194 + After this, `Networking.swift` shrinks by ~50 lines. The whole DPoP proof construction lives in one file. 195 + 196 + Tests to update: 197 + - `DPoPStoreTests.swift` continues to test `DPoPNonceStore` for now. The new `DPoPSignerTests` covers the new path. 198 + - Add an integration test in `OAuthTests.swift` that drives a fake `URLResponseProvider`, sees a `DPoP-Nonce` header, and confirms the next outbound proof carries the nonce. 199 + 200 + ### Step 3 — Centralize URL canonicalization + origin helpers 201 + 202 + The `htu` and origin logic now lives in `DPoPSigner` (steps 1–2). Two more places use it: 203 + - `ATProtoOAuth.normalizedOrigin(_:)` (line 901) — for issuer comparison. 204 + - `IdentityResolver.normalizedOrigin(from:)` (line 287) — for auth-server-vs-PDS check. 205 + 206 + Both are private and trivially identical. Lift them into a small file `Sources/CoreATProtocol/OAuth/URLOrigin.swift`: 207 + 208 + ```swift 209 + enum URLOrigin { 210 + static func normalized(_ url: URL) -> String? { 211 + guard let scheme = url.scheme?.lowercased(), 212 + let host = url.host?.lowercased() else { return nil } 213 + let port = url.port.map { ":\($0)" } ?? "" 214 + return "\(scheme)://\(host)\(port)" 215 + } 216 + } 217 + ``` 218 + 219 + Update three call sites (`DPoPSigner.origin`, `ATProtoOAuth.normalizedOrigin`, `IdentityResolver.normalizedOrigin`) to call `URLOrigin.normalized`. Internal helper, no public API change. 220 + 221 + ### Step 4 — Extract the token validator closure 222 + 223 + `ATProtoOAuth.loginProvider` (line 759) inlines all the token-response validation: DPoP token type, `atproto` scope, issuer matches expected auth server, sub matches expected DID. Same checks duplicate in `refreshProvider` (line 834). 224 + 225 + Extract to a private helper: 226 + 227 + ```swift 228 + private struct TokenValidator: Sendable { 229 + let expectedAuthorizationServer: String 230 + let expectedSubjectDID: String? 231 + 232 + func validate( 233 + response: OAuthTokenResponse, 234 + actualIssuer: String 235 + ) throws { 236 + guard response.tokenType == "DPoP" else { 237 + throw AuthenticatorError.dpopTokenExpected(response.tokenType) 238 + } 239 + guard response.scopes.contains("atproto") else { 240 + throw ATProtoOAuthError.missingRequiredScope("atproto") 241 + } 242 + try ATProtoOAuth.validateIssuer(actualIssuer, matches: expectedAuthorizationServer) 243 + if let expectedSubjectDID, response.subject != expectedSubjectDID { 244 + throw ATProtoOAuthError.subjectMismatch( 245 + expected: expectedSubjectDID, 246 + actual: response.subject 247 + ) 248 + } 249 + } 250 + } 251 + ``` 252 + 253 + Both providers call `validator.validate(response:actualIssuer:)`. Worth ~30 lines saved and the validator becomes directly unit-testable. 254 + 255 + Tests: add `TokenValidatorTests` covering the four failure modes (wrong token type, missing scope, issuer mismatch, sub mismatch) and the success path. 256 + 257 + ### Step 5 — Make `IdentityResolver` a protocol 258 + 259 + Today `IdentityResolver` is a concrete struct. Tests have to hit live PDS endpoints (see `OAuthTests.swift` line 11-25 — calls `atproto.com`). That's fine for a smoke test, terrible for CI determinism. 260 + 261 + Introduce: 262 + 263 + ```swift 264 + public protocol ATProtoIdentityResolver: Sendable { 265 + func resolve(identifier: String) async throws -> IdentityResolver.ResolvedIdentity 266 + func isAuthorizationServer(_ authorizationServer: String, validFor pdsEndpoint: String) async throws -> Bool 267 + } 268 + 269 + extension IdentityResolver: ATProtoIdentityResolver {} 270 + ``` 271 + 272 + Add an injection point on `ATProtoOAuth.init`: 273 + 274 + ```swift 275 + public init( 276 + config: ATProtoOAuthConfig, 277 + storage: ATProtoAuthStorage, 278 + identityResolver: ATProtoIdentityResolver = IdentityResolver() 279 + ) async { ... } 280 + ``` 281 + 282 + Don't add a "fallback resolver" yet — YAGNI until you actually need a Slingshot/community DoH backup. Just open the seam. 283 + 284 + Tests: replace the live-network identity tests in `OAuthTests.swift` with a fake `ATProtoIdentityResolver` that returns canned `ResolvedIdentity` values. Keep one live integration test, but mark it with `@Test(.disabled(if: ...))` or a tag so it's opt-in. 285 + 286 + ### Step 6 — Optional: split `ATProtoOAuth.swift` along seams 287 + 288 + Only do this if Steps 1–5 still leave the file feeling unwieldy. The seams are clear: 289 + 290 + - `ATProtoOAuth.swift` (~400 lines) — `ATProtoOAuth` class, public config, public errors. 291 + - `OAuth/TokenHandling+ATProto.swift` (~250 lines) — `buildTokenHandling`, `authorizationURLProvider`, `loginProvider`, `refreshProvider` extracted as private extensions on `ATProtoOAuth`. 292 + - `OAuth/TokenModels.swift` (~150 lines) — `OAuthTokenRequest`, `OAuthRefreshTokenRequest`, `OAuthTokenResponse`. 293 + 294 + Don't change behavior in this step — pure file move + access-control tightening. 295 + 296 + Once the split lands, drop the deprecated `ATProtoSession.dpopPrivateKey` / `dpopKeys` / `dpopNonceStore` accessors (Step 2 deprecated them). Remove `DPoPNonceStore.swift`. Bump CoreATProtocol's tag — this is a SemVer-major change because the deprecated symbols are public. 297 + 298 + ### Step 7 — Update auto-memory 299 + 300 + After all of this lands, update `~/.claude/projects/-Users-rademaker-Developer-SparrowTek-AtProto/memory/MEMORY.md`: 301 + - Note that DPoP signing is consolidated in `OAuth/DPoPSigner.swift`. 302 + - Note the per-origin nonce cache (so future-Claude doesn't try to re-introduce a single-slot store). 303 + - Note that Atprosphere uses `clientAuthMethod: .privateKeyJWT`, effem uses `.none`, and the auth proxy path is exercised in production by both. 304 + - Drop the stale Plume entries — that directory no longer exists in this checkout. 305 + 306 + ## What we're explicitly NOT doing 307 + 308 + - **Not switching to AtprotoOAuth.** Their README says it's not ready, they don't support `private_key_jwt`, and adopting them means adopting AtprotoTypes/AtprotoClient too. 309 + - **Not removing `clientAuthMethod` or `AuthProxy.swift`.** Atprosphere's production OAuth depends on the confidential-client + auth-proxy path. 310 + - **Not changing the public API surface in Atprosphere/effem.** Steps 1–5 are additive or internal. Only Step 6's deprecation removal is breaking, and that's gated behind a SemVer bump. 311 + - **Not introducing new dependencies.** `swift-crypto` is already pulled in transitively via `jwt-kit`; everything else is Foundation. 312 + 313 + ## Risk + rollback 314 + 315 + | Step | Risk | Rollback | 316 + | --- | --- | --- | 317 + | 1 (add DPoPSigner) | Low — new file, not wired in. | Delete the file. | 318 + | 2 (wire in DPoPSigner) | Medium — touches every authenticated request. | Revert; deprecated accessors still work. | 319 + | 3 (URL canonicalization) | Low — pure refactor. | Revert. | 320 + | 4 (extract validator) | Low — pure refactor. | Revert. | 321 + | 5 (resolver protocol) | Low — additive. | Revert. | 322 + | 6 (file split + deprecation removal) | Medium — public API change. | SemVer-major bump means consumers opt in. | 323 + 324 + ## Order of work, in PR-size chunks 325 + 326 + 1. **PR 1**: Step 1 — `DPoPSigner` + tests. Standalone, no consumer changes. **DONE**, bundled with PR 2. 327 + 2. **PR 2**: Step 2 — wire `DPoPSigner` into `Networking.swift` + `ATProtoOAuth.swift`. Largest diff. Verify against Atprosphere + effem before merge. **DONE**. 328 + 3. **PR 3**: Steps 3 + 4 — URL canonicalization + token validator. Small refactor PR. **NEXT**. 329 + 4. **PR 4**: Step 5 — resolver protocol + offline tests. 330 + 5. **PR 5** (later, behind a SemVer bump): Step 6 — file split and deprecation removal. 331 + 332 + Steps 1–5 are non-breaking. Step 6 is breaking and can wait until you have another reason to bump the major version. 333 + 334 + ## Notes from the Steps 1+2 implementation 335 + 336 + - **Renamed the new actor to `DPoPProofSigner`.** OAuthenticator already exports `public final class DPoPSigner` (its `JWTGenerator` typealias is the entry point we hand to `TokenHandling`), so the plan's name would have collided. The new file is `Sources/CoreATProtocol/OAuth/DPoPProofSigner.swift`; everywhere the plan says `DPoPSigner` for our new actor, the code says `DPoPProofSigner`. OAuthenticator's `DPoPSigner` keeps its name and role (per-flow nonce holder for `Authenticator`). 337 + - **Added an `explicitNonce` parameter to `ProofParameters`.** The auth-server flow runs through `OAuthenticator.DPoPSigner`, which already tracks the auth-server nonce for the exchange and passes it via `JWTParameters.nonce`. We forward that as `explicitNonce` so the call doesn't consult the per-origin cache (which is the XRPC layer's). The XRPC layer in `Networking.swift` calls `sign(_:)` without an `explicitNonce` and the cache is consulted normally. 338 + - **Did not deprecate `dpopPrivateKey` / `dpopKeys` / `dpopNonceStore` yet.** The plan calls for `@available(*, deprecated, ...)` on those public properties, but applying it now would require `@available`-aware suppression at the write sites in `setDPoPPrivateKey(pem:)`. Cleaner to defer until Step 6, which removes them outright. The new `dpopProofSigner` accessor is the production read path; the legacy fields are dual-written in `setDPoPPrivateKey(pem:)` purely for source compatibility with external readers. 339 + - **Dropped the integration test from Step 2.** The plan suggested an end-to-end test that drives `APRouterDelegate.didReceiveErrorResponse` then `intercept` and verifies the cached nonce flows through. Implementation revealed that any test touching `ATProtoSession.shared` races with other suites that call `await ATProtoSession.shared.reset()` (`NonceDetectionTests`, `RefreshLoginTests`, `DPoPTests`). Swift Testing's `.serialized` trait only orders within a suite, not across suites. Two paths are open here: (1) globally serialize all session-touching suites, or (2) make the session instance-based so tests can spin up isolated sessions. (2) is the long-term fix and is closer to what's contemplated in `APEnvironment.swift`'s "future major release will make it instance-based" comment. Until then, the per-origin caching is fully covered by the unit tests in `DPoPProofSignerTests`, and the wiring through `Networking.swift` is straightforward delegation (the kind of code production traffic exercises immediately). 340 + - **Verified consumers.** bskyKit and EffemKit were rebuilt against the local CoreATProtocol checkout via a temporary `.package(path:)` swap — both succeeded. Atprosphere and the effem iOS app are `.xcodeproj` consumers; their call sites were inspected and only touch preserved public APIs (`setDPoPPrivateKey(pem:)`, `ATProtoOAuth(config:storage:)`, `ATProtoOAuthConfig`, `ATProtoOAuthError`, `ATProtoSession.shared.host`, `ATProtoSession.shared.routerDelegate`). 341 + - **Updated test:** `nonceCacheBounded` was rewritten — the original draft asserted "≥ 1 of the 5 oldest origins was evicted," which was probabilistic since Swift dictionaries don't guarantee key ordering. The new assertion is the deterministic invariant: after 30 insertions into a 25-slot cache, exactly 25 entries survive.