···2525 public var tokenRefreshHandler: (@Sendable () async throws -> Bool)?
2626 public var dpopPrivateKey: ES256PrivateKey?
2727 public var dpopKeys: JWTKeyCollection?
2828+ /// Per-session signer that owns the DPoP key and the per-origin nonce
2929+ /// cache (RFC 9449). Populated by ``setDPoPPrivateKey(pem:)``; production
3030+ /// signing paths read from this property rather than the legacy
3131+ /// ``dpopPrivateKey`` / ``dpopKeys`` / ``dpopNonceStore`` fields, which
3232+ /// are retained for source compatibility until the next major release.
3333+ public var dpopProofSigner: DPoPProofSigner?
2834 public let dpopNonceStore = DPoPNonceStore()
2935 public let clockSkewStore = ClockSkewStore()
3036 public let routerDelegate = APRouterDelegate()
···4450 tokenRefreshHandler = nil
4551 dpopPrivateKey = nil
4652 dpopKeys = nil
5353+ dpopProofSigner = nil
4754 await dpopNonceStore.clear()
4855 await clockSkewStore.update(offset: 0)
4956 }
+11-1
Sources/CoreATProtocol/CoreATProtocol.swift
···5757 guard let pem, !pem.isEmpty else {
5858 ATProtoSession.shared.dpopPrivateKey = nil
5959 ATProtoSession.shared.dpopKeys = nil
6060+ ATProtoSession.shared.dpopProofSigner = nil
6061 return
6162 }
62636364 let privateKey = try ES256PrivateKey(pem: pem)
6565+ let signer = await DPoPProofSigner(
6666+ privateKey: privateKey,
6767+ clockSkew: ATProtoSession.shared.clockSkewStore
6868+ )
6969+ ATProtoSession.shared.dpopProofSigner = signer
7070+7171+ // Backward-compat: keep the legacy fields populated until they are
7272+ // removed in a future major release. Production signing paths read from
7373+ // ``dpopProofSigner``; these are only here so external callers that read
7474+ // the public fields keep observing a non-nil value.
6475 let keys = JWTKeyCollection()
6576 await keys.add(ecdsa: privateKey)
6666-6777 ATProtoSession.shared.dpopPrivateKey = privateKey
6878 ATProtoSession.shared.dpopKeys = keys
6979}
+14-70
Sources/CoreATProtocol/Networking.swift
···66//
7788import Foundation
99-import Crypto
1010-import JWTKit
119import NetworkingKit
1210import OAuthenticator
1311import os
···5553 public func intercept(_ request: inout URLRequest) async {
5654 guard let accessToken = await ATProtoSession.shared.accessToken else { return }
57555858- if let dpopKey = await ATProtoSession.shared.dpopPrivateKey,
5959- let keys = await ATProtoSession.shared.dpopKeys {
6060- // DPoP-bound token: use "DPoP" scheme + DPoP proof header
5656+ if let signer = await ATProtoSession.shared.dpopProofSigner,
5757+ let url = request.url,
5858+ let method = request.httpMethod {
6159 do {
6262- let proof = try await generateDPoPProof(for: request, accessToken: accessToken, privateKey: dpopKey, keys: keys)
6060+ let proof = try await signer.sign(.init(
6161+ httpMethod: method,
6262+ url: url,
6363+ accessToken: accessToken
6464+ ))
6365 request.setValue("DPoP \(accessToken)", forHTTPHeaderField: "Authorization")
6466 request.setValue(proof, forHTTPHeaderField: "DPoP")
6567 } catch {
···7880 }
7981 }
80828181- private func generateDPoPProof(
8282- for request: URLRequest,
8383- accessToken: String,
8484- privateKey: ES256PrivateKey,
8585- keys: JWTKeyCollection
8686- ) async throws -> String {
8787- guard let method = request.httpMethod,
8888- let url = request.url else {
8989- throw AtError.message(ErrorMessage(error: "DPoPProofInvalidRequest", message: "Request is missing method or URL"))
9090- }
9191-9292- // Strip query and fragment per DPoP spec
9393- var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
9494- components?.query = nil
9595- components?.fragment = nil
9696- let htu = components?.url?.absoluteString ?? url.absoluteString
9797-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 ATProtoSession.shared.dpopNonceStore.get()
101101- let issuedAt = await ATProtoSession.shared.clockSkewStore.serverAdjustedNow()
102102-103103- // ath: base64url-encoded SHA-256 hash of the access token (RFC 9449 §4.2)
104104- let hash = SHA256.hash(data: Data(accessToken.utf8))
105105- let ath = Data(hash).base64URLEncodedString()
106106-107107- let payload = DPoPProofPayload(
108108- htm: method,
109109- htu: htu,
110110- iat: .init(value: issuedAt),
111111- jti: .init(value: UUID().uuidString),
112112- nonce: nonce,
113113- ath: ath
114114- )
115115-116116- var header = JWTHeader()
117117- header.typ = "dpop+jwt"
118118- header.alg = "ES256"
119119-120120- if let keyParams = privateKey.parameters {
121121- header.jwk = [
122122- "kty": .string("EC"),
123123- "crv": .string("P-256"),
124124- "x": .string(keyParams.x.base64URLEncoded()),
125125- "y": .string(keyParams.y.base64URLEncoded()),
126126- ]
127127- }
128128-129129- return try await keys.sign(payload, header: header)
130130- }
131131-13283 public func didReceiveErrorResponse(_ response: HTTPURLResponse) async {
13384 // Record clock skew from any response that carries a Date header.
13485 // DPoP rejections often correlate with skew, so harvest this eagerly.
···14091 let headerNonce = response.value(forHTTPHeaderField: "DPoP-Nonce")
14192 ?? response.value(forHTTPHeaderField: "dpop-nonce")
14293 if let headerNonce {
9494+ if let url = response.url,
9595+ let signer = await ATProtoSession.shared.dpopProofSigner {
9696+ await signer.cacheNonce(headerNonce, from: url)
9797+ }
9898+ // Backward-compat: keep the single-slot store updated for
9999+ // external readers of the public ``dpopNonceStore`` accessor.
143100 await ATProtoSession.shared.dpopNonceStore.update(headerNonce)
144101 lastErrorHadNonceHeader = true
145102 } else {
···215172 // nonce-challenge signal even when the body is absent or malformed.
216173 return lastErrorHadNonceHeader
217174 }
218218-}
219219-220220-// MARK: - DPoP Proof Payload
221221-222222-private struct DPoPProofPayload: JWTPayload {
223223- let htm: String
224224- let htu: String
225225- let iat: IssuedAtClaim
226226- let jti: IDClaim
227227- let nonce: String?
228228- let ath: String?
229229-230230- func verify(using key: some JWTAlgorithm) throws {}
231175}
232176233177private enum DateParser {
+28-70
Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift
···157157 private let dpopRequestActor = DPoPRequestActor()
158158 private var hasPersistedKey: Bool
159159160160- // JWT signing keys (pattern from AtProtocol)
161161- private var keys: JWTKeyCollection
162160 private var privateKey: ES256PrivateKey
161161+ private var proofSigner: DPoPProofSigner
163162164163 private struct AuthenticationAttemptResult {
165164 let login: Login
···171170 self.storage = storage
172171 self.identityResolver = IdentityResolver()
173172174174- // Initialize JWT keys (from AtProto.swift lines 19-23)
175173 if let storedKeyData = try? await storage.retrievePrivateKey(),
176174 let pem = String(data: storedKeyData, encoding: .utf8),
177175 let restoredKey = try? ES256PrivateKey(pem: pem) {
···181179 self.privateKey = ES256PrivateKey()
182180 self.hasPersistedKey = false
183181 }
184184- self.keys = JWTKeyCollection()
185185- await self.keys.add(ecdsa: privateKey)
182182+ self.proofSigner = await DPoPProofSigner(
183183+ privateKey: privateKey,
184184+ clockSkew: ATProtoSession.shared.clockSkewStore
185185+ )
186186 }
187187188188 /// Initialize with existing private key (for session restoration)
···191191 self.storage = storage
192192 self.identityResolver = IdentityResolver()
193193194194- // Restore existing key
195194 self.privateKey = try ES256PrivateKey(pem: privateKeyPEM)
196195 self.hasPersistedKey = true
197197- self.keys = JWTKeyCollection()
198198- await self.keys.add(ecdsa: privateKey)
196196+ self.proofSigner = await DPoPProofSigner(
197197+ privateKey: privateKey,
198198+ clockSkew: ATProtoSession.shared.clockSkewStore
199199+ )
199200 }
200201201202 /// Authenticate user by handle
···523524 privateKey.pemRepresentation
524525 }
525526526526- // MARK: - Private (from AtProto.swift lines 60-72)
527527+ // MARK: - Private
527528528529 private func generateJWT(params: DPoPSigner.JWTParameters) async throws -> String {
529529- // Strip query params and fragments from htu per DPoP spec
530530- let htu = stripQueryAndFragment(from: params.requestEndpoint)
531531-532532- let issuedAt = await ATProtoSession.shared.clockSkewStore.serverAdjustedNow()
533533-534534- let payload = DPoPPayload(
535535- htm: params.httpMethod,
536536- htu: htu,
537537- iat: .init(value: issuedAt),
538538- jti: .init(value: UUID().uuidString),
539539- nonce: params.nonce
540540- )
541541-542542- // DPoP requires typ="dpop+jwt", alg="ES256", and the public key in jwk header
543543- var header = JWTHeader()
544544- header.typ = "dpop+jwt"
545545- header.alg = "ES256"
546546-547547- // Get public key parameters and convert to base64url for JWK
548548- if let keyParams = privateKey.parameters {
549549- header.jwk = [
550550- "kty": .string("EC"),
551551- "crv": .string("P-256"),
552552- "x": .string(keyParams.x.base64URLEncoded()),
553553- "y": .string(keyParams.y.base64URLEncoded())
554554- ]
555555- }
556556-557557- return try await self.keys.sign(payload, header: header)
558558- }
559559-560560- /// Strip query string and fragment from URL per DPoP spec
561561- private func stripQueryAndFragment(from url: String) -> String {
562562- let fragmentIndex = url.firstIndex(of: "#").map { url.distance(from: url.startIndex, to: $0) } ?? -1
563563- let queryIndex = url.firstIndex(of: "?").map { url.distance(from: url.startIndex, to: $0) } ?? -1
564564-565565- let end: Int
566566- if fragmentIndex == -1 {
567567- end = queryIndex
568568- } else if queryIndex == -1 {
569569- end = fragmentIndex
570570- } else {
571571- end = min(fragmentIndex, queryIndex)
530530+ guard let url = URL(string: params.requestEndpoint) else {
531531+ throw ATProtoOAuthError.malformedServerMetadata(
532532+ field: "request_endpoint",
533533+ value: params.requestEndpoint
534534+ )
572535 }
573573-574574- return end == -1 ? url : String(url.prefix(end))
536536+ // OAuthenticator's `DPoPSigner` already tracks the auth-server nonce
537537+ // for this exchange — pass it through so we don't consult the
538538+ // proof signer's per-origin cache (which is owned by the XRPC layer).
539539+ return try await proofSigner.sign(.init(
540540+ httpMethod: params.httpMethod,
541541+ url: url,
542542+ accessToken: nil,
543543+ explicitNonce: params.nonce
544544+ ))
575545 }
576546577547 private func stripScheme(from url: String) -> String {
···594564595565 private func resetDPoPKey() async throws {
596566 privateKey = ES256PrivateKey()
597597- keys = JWTKeyCollection()
598598- await keys.add(ecdsa: privateKey)
567567+ proofSigner = await DPoPProofSigner(
568568+ privateKey: privateKey,
569569+ clockSkew: ATProtoSession.shared.clockSkewStore
570570+ )
599571 hasPersistedKey = false
600572 try await persistPrivateKey()
601573 }
···905877 }
906878 let port = url.port.map { ":\($0)" } ?? ""
907879 return "\(scheme)://\(host)\(port)"
908908- }
909909-}
910910-911911-// MARK: - DPoP Payload (from AtProto.swift lines 88-98)
912912-913913-private struct DPoPPayload: JWTPayload {
914914- let htm: String
915915- let htu: String
916916- let iat: IssuedAtClaim
917917- let jti: IDClaim
918918- let nonce: String?
919919-920920- func verify(using key: some JWTAlgorithm) throws {
921921- // No additional verification needed for DPoP
922880 }
923881}
924882
···11+# OAuth update plan for CoreATProtocol
22+33+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.
44+55+## Status (2026-04-29)
66+77+- **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.
88+- **Steps 3–5 are next.** They're independent of each other and any pair of them is a reasonable single-session chunk.
99+- **Step 6 remains gated** behind a SemVer-major bump.
1010+1111+See "Notes from the Steps 1+2 implementation" near the bottom for the deviations from the original plan.
1212+1313+## Inventory of what we have today
1414+1515+Files (in `Sources/CoreATProtocol/`):
1616+- `OAuth/ATProtoOAuth.swift` — 1016 lines. Authorize / refresh / DPoP JWT generation / token persistence. Single class doing everything.
1717+- `OAuth/AuthProxy.swift` — 294 lines. `URLResponseProvider` interceptor that re-routes PAR + token requests through an auth proxy, with bypass on transport failure.
1818+- `OAuth/IdentityResolver.swift` — 366 lines. Handle ↔ DID ↔ PDS ↔ auth server resolution + bidirectional handle verification.
1919+- `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.
2020+- `APEnvironment.swift` — `ATProtoSession.shared` singleton holding tokens + DPoP key + JWT key collection + nonce store + clock skew store.
2121+- `DPoPNonceStore.swift` — single-slot actor for the most recent server nonce.
2222+- `ClockSkewStore.swift` — observed offset between local and server clocks.
2323+- `TokenRefreshCoordinator.swift` — coalesces concurrent refresh attempts.
2424+- `Models/` — `Session`, `AtError`, etc.
2525+2626+Consumers:
2727+- `bskyKit` re-exports `ATProtoOAuth` as `BskyOAuth` (via `@_exported import CoreATProtocol`).
2828+- `Atprosphere` calls `ATProtoOAuth(config:storage:)` with `clientAuthMethod: .privateKeyJWT` + `authProxyBaseURL`.
2929+- `effem` calls `ATProtoOAuth(config:storage:)` with `authProxyBaseURL` only (default `.none`).
3030+- `ATProtoCLI` does not use OAuth.
3131+3232+Tests: 1012 lines across 9 files, including `OAuthTests.swift` (446 lines) covering proxy request encoding, key ID storage, DPoP key persistence, and identity resolution.
3333+3434+## What's worth borrowing from AtprotoOAuth
3535+3636+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.
3737+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.
3838+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.
3939+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.
4040+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.
4141+4242+What we should *not* borrow:
4343+- `AtprotoOAuthAgent`/`AuthPDSAgent` — couples to germ's XRPC abstraction.
4444+- `OAuth.SessionState.Archive` — our `Login`/`storage` callbacks already do the same job.
4545+- AtprotoTypes `Atproto.DID`/`Handle`/`DIDDocument` — we have our own types, no need to swap.
4646+- The `AsyncStream` save model — a callback closure (what we have) is simpler and equally correct.
4747+4848+## Plan
4949+5050+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.
5151+5252+### Step 1 — Add `DPoPSigner` (the consolidated signer actor) — DONE
5353+5454+**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.
5555+5656+Create `Sources/CoreATProtocol/OAuth/DPoPSigner.swift`:
5757+5858+```swift
5959+import Foundation
6060+import Crypto
6161+import JWTKit
6262+6363+/// Owns the DPoP signing key, the per-origin nonce cache, and JWT generation.
6464+///
6565+/// One `DPoPSigner` per logical session. Nonces are keyed by origin
6666+/// (`scheme://host[:port]`) per RFC 9449 — the auth server and the PDS have
6767+/// independent nonce streams, and using the wrong one causes a spurious
6868+/// `use_dpop_nonce` challenge on the next request.
6969+public actor DPoPSigner {
7070+ public struct ProofParameters: Sendable {
7171+ public let httpMethod: String
7272+ public let url: URL
7373+ /// Access token to bind via `ath`. Pass `nil` for unauthenticated
7474+ /// requests (PAR, token exchange).
7575+ public let accessToken: String?
7676+ }
7777+7878+ private let privateKey: ES256PrivateKey
7979+ private let keys: JWTKeyCollection
8080+ private var noncesByOrigin: [String: String] = [:]
8181+ private let nonceCacheLimit = 25
8282+ private let clockSkew: ClockSkewStore
8383+8484+ public init(privateKey: ES256PrivateKey, clockSkew: ClockSkewStore) async {
8585+ self.privateKey = privateKey
8686+ let keys = JWTKeyCollection()
8787+ await keys.add(ecdsa: privateKey)
8888+ self.keys = keys
8989+ self.clockSkew = clockSkew
9090+ }
9191+9292+ public convenience init(clockSkew: ClockSkewStore) async {
9393+ await self.init(privateKey: ES256PrivateKey(), clockSkew: clockSkew)
9494+ }
9595+9696+ public var pemRepresentation: String { privateKey.pemRepresentation }
9797+9898+ public func sign(_ params: ProofParameters) async throws -> String {
9999+ let htu = Self.canonicalHTU(params.url)
100100+ let origin = Self.origin(for: params.url)
101101+ let nonce = origin.flatMap { noncesByOrigin[$0] }
102102+ let issuedAt = await clockSkew.serverAdjustedNow()
103103+ let ath = params.accessToken.map(Self.athClaim)
104104+105105+ var header = JWTHeader()
106106+ header.typ = "dpop+jwt"
107107+ header.alg = "ES256"
108108+ if let p = privateKey.parameters {
109109+ header.jwk = [
110110+ "kty": .string("EC"), "crv": .string("P-256"),
111111+ "x": .string(p.x.base64URLEncoded()),
112112+ "y": .string(p.y.base64URLEncoded()),
113113+ ]
114114+ }
115115+116116+ let payload = DPoPProofPayload(
117117+ htm: params.httpMethod, htu: htu,
118118+ iat: .init(value: issuedAt),
119119+ jti: .init(value: UUID().uuidString),
120120+ nonce: nonce, ath: ath
121121+ )
122122+ return try await keys.sign(payload, header: header)
123123+ }
124124+125125+ /// Records a nonce against the origin of `url`, evicting the oldest entry
126126+ /// if the cache exceeds its limit.
127127+ public func cacheNonce(_ nonce: String, from url: URL) {
128128+ guard let origin = Self.origin(for: url) else { return }
129129+ if noncesByOrigin.count >= nonceCacheLimit, noncesByOrigin[origin] == nil {
130130+ // Drop one arbitrary entry. A true LRU is overkill — bounded growth is the goal.
131131+ if let key = noncesByOrigin.keys.first { noncesByOrigin.removeValue(forKey: key) }
132132+ }
133133+ noncesByOrigin[origin] = nonce
134134+ }
135135+136136+ public func clearNonces() { noncesByOrigin.removeAll() }
137137+138138+ // MARK: - Helpers
139139+140140+ static func canonicalHTU(_ url: URL) -> String {
141141+ var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
142142+ components?.query = nil
143143+ components?.fragment = nil
144144+ return components?.url?.absoluteString ?? url.absoluteString
145145+ }
146146+147147+ static func origin(for url: URL) -> String? {
148148+ guard let scheme = url.scheme?.lowercased(),
149149+ let host = url.host?.lowercased() else { return nil }
150150+ let port = url.port.map { ":\($0)" } ?? ""
151151+ return "\(scheme)://\(host)\(port)"
152152+ }
153153+154154+ static func athClaim(for accessToken: String) -> String {
155155+ let hash = SHA256.hash(data: Data(accessToken.utf8))
156156+ return Data(hash).base64URLEncodedString()
157157+ }
158158+}
159159+160160+private struct DPoPProofPayload: JWTPayload {
161161+ let htm: String
162162+ let htu: String
163163+ let iat: IssuedAtClaim
164164+ let jti: IDClaim
165165+ let nonce: String?
166166+ let ath: String?
167167+ func verify(using key: some JWTAlgorithm) throws {}
168168+}
169169+```
170170+171171+Tests to add (Swift Testing):
172172+- `DPoPSignerTests.signEmitsDpopJwtType` — verify header `typ=dpop+jwt`, `alg=ES256`, JWK has `kty/crv/x/y`.
173173+- `DPoPSignerTests.htuStripsQueryAndFragment` — `https://x/y?z#a` → `https://x/y`.
174174+- `DPoPSignerTests.athPresentOnlyWhenAccessTokenProvided` — round-trip JWT, decode payload, check `ath`.
175175+- `DPoPSignerTests.nonceCachedPerOrigin` — cache nonces for two origins, verify each origin gets its own nonce in the next signed proof.
176176+- `DPoPSignerTests.nonceCacheBounded` — push 30 origins, assert ≤ 25 entries.
177177+- `DPoPSignerTests.iatRespectsClockSkew` — set `ClockSkewStore` offset to +600, sign, decode `iat`, assert ≈ now + 600.
178178+179179+Don't wire it into `Networking.swift` or `ATProtoOAuth.swift` yet — that's Step 2.
180180+181181+### Step 2 — Replace ad-hoc DPoP signing in `Networking.swift` and `ATProtoOAuth.swift` — DONE
182182+183183+`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.
184184+185185+Changes:
186186+- Add `var dpopSigner: DPoPSigner?` to `ATProtoSession`.
187187+- Update `setDPoPPrivateKey(pem:)` in `CoreATProtocol.swift` to instantiate `DPoPSigner` instead of populating `dpopPrivateKey` + `dpopKeys`.
188188+- Replace `APRouterDelegate.generateDPoPProof` body with `try await signer.sign(.init(httpMethod: method, url: url, accessToken: accessToken))`.
189189+- 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.
190190+- 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).
191191+- 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.
192192+- Keep the existing `dpopPrivateKey` / `dpopKeys` accessors on `ATProtoSession`, but mark them deprecated. They're public, so removing them is a major-version change.
193193+194194+After this, `Networking.swift` shrinks by ~50 lines. The whole DPoP proof construction lives in one file.
195195+196196+Tests to update:
197197+- `DPoPStoreTests.swift` continues to test `DPoPNonceStore` for now. The new `DPoPSignerTests` covers the new path.
198198+- 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.
199199+200200+### Step 3 — Centralize URL canonicalization + origin helpers
201201+202202+The `htu` and origin logic now lives in `DPoPSigner` (steps 1–2). Two more places use it:
203203+- `ATProtoOAuth.normalizedOrigin(_:)` (line 901) — for issuer comparison.
204204+- `IdentityResolver.normalizedOrigin(from:)` (line 287) — for auth-server-vs-PDS check.
205205+206206+Both are private and trivially identical. Lift them into a small file `Sources/CoreATProtocol/OAuth/URLOrigin.swift`:
207207+208208+```swift
209209+enum URLOrigin {
210210+ static func normalized(_ url: URL) -> String? {
211211+ guard let scheme = url.scheme?.lowercased(),
212212+ let host = url.host?.lowercased() else { return nil }
213213+ let port = url.port.map { ":\($0)" } ?? ""
214214+ return "\(scheme)://\(host)\(port)"
215215+ }
216216+}
217217+```
218218+219219+Update three call sites (`DPoPSigner.origin`, `ATProtoOAuth.normalizedOrigin`, `IdentityResolver.normalizedOrigin`) to call `URLOrigin.normalized`. Internal helper, no public API change.
220220+221221+### Step 4 — Extract the token validator closure
222222+223223+`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).
224224+225225+Extract to a private helper:
226226+227227+```swift
228228+private struct TokenValidator: Sendable {
229229+ let expectedAuthorizationServer: String
230230+ let expectedSubjectDID: String?
231231+232232+ func validate(
233233+ response: OAuthTokenResponse,
234234+ actualIssuer: String
235235+ ) throws {
236236+ guard response.tokenType == "DPoP" else {
237237+ throw AuthenticatorError.dpopTokenExpected(response.tokenType)
238238+ }
239239+ guard response.scopes.contains("atproto") else {
240240+ throw ATProtoOAuthError.missingRequiredScope("atproto")
241241+ }
242242+ try ATProtoOAuth.validateIssuer(actualIssuer, matches: expectedAuthorizationServer)
243243+ if let expectedSubjectDID, response.subject != expectedSubjectDID {
244244+ throw ATProtoOAuthError.subjectMismatch(
245245+ expected: expectedSubjectDID,
246246+ actual: response.subject
247247+ )
248248+ }
249249+ }
250250+}
251251+```
252252+253253+Both providers call `validator.validate(response:actualIssuer:)`. Worth ~30 lines saved and the validator becomes directly unit-testable.
254254+255255+Tests: add `TokenValidatorTests` covering the four failure modes (wrong token type, missing scope, issuer mismatch, sub mismatch) and the success path.
256256+257257+### Step 5 — Make `IdentityResolver` a protocol
258258+259259+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.
260260+261261+Introduce:
262262+263263+```swift
264264+public protocol ATProtoIdentityResolver: Sendable {
265265+ func resolve(identifier: String) async throws -> IdentityResolver.ResolvedIdentity
266266+ func isAuthorizationServer(_ authorizationServer: String, validFor pdsEndpoint: String) async throws -> Bool
267267+}
268268+269269+extension IdentityResolver: ATProtoIdentityResolver {}
270270+```
271271+272272+Add an injection point on `ATProtoOAuth.init`:
273273+274274+```swift
275275+public init(
276276+ config: ATProtoOAuthConfig,
277277+ storage: ATProtoAuthStorage,
278278+ identityResolver: ATProtoIdentityResolver = IdentityResolver()
279279+) async { ... }
280280+```
281281+282282+Don't add a "fallback resolver" yet — YAGNI until you actually need a Slingshot/community DoH backup. Just open the seam.
283283+284284+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.
285285+286286+### Step 6 — Optional: split `ATProtoOAuth.swift` along seams
287287+288288+Only do this if Steps 1–5 still leave the file feeling unwieldy. The seams are clear:
289289+290290+- `ATProtoOAuth.swift` (~400 lines) — `ATProtoOAuth` class, public config, public errors.
291291+- `OAuth/TokenHandling+ATProto.swift` (~250 lines) — `buildTokenHandling`, `authorizationURLProvider`, `loginProvider`, `refreshProvider` extracted as private extensions on `ATProtoOAuth`.
292292+- `OAuth/TokenModels.swift` (~150 lines) — `OAuthTokenRequest`, `OAuthRefreshTokenRequest`, `OAuthTokenResponse`.
293293+294294+Don't change behavior in this step — pure file move + access-control tightening.
295295+296296+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.
297297+298298+### Step 7 — Update auto-memory
299299+300300+After all of this lands, update `~/.claude/projects/-Users-rademaker-Developer-SparrowTek-AtProto/memory/MEMORY.md`:
301301+- Note that DPoP signing is consolidated in `OAuth/DPoPSigner.swift`.
302302+- Note the per-origin nonce cache (so future-Claude doesn't try to re-introduce a single-slot store).
303303+- Note that Atprosphere uses `clientAuthMethod: .privateKeyJWT`, effem uses `.none`, and the auth proxy path is exercised in production by both.
304304+- Drop the stale Plume entries — that directory no longer exists in this checkout.
305305+306306+## What we're explicitly NOT doing
307307+308308+- **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.
309309+- **Not removing `clientAuthMethod` or `AuthProxy.swift`.** Atprosphere's production OAuth depends on the confidential-client + auth-proxy path.
310310+- **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.
311311+- **Not introducing new dependencies.** `swift-crypto` is already pulled in transitively via `jwt-kit`; everything else is Foundation.
312312+313313+## Risk + rollback
314314+315315+| Step | Risk | Rollback |
316316+| --- | --- | --- |
317317+| 1 (add DPoPSigner) | Low — new file, not wired in. | Delete the file. |
318318+| 2 (wire in DPoPSigner) | Medium — touches every authenticated request. | Revert; deprecated accessors still work. |
319319+| 3 (URL canonicalization) | Low — pure refactor. | Revert. |
320320+| 4 (extract validator) | Low — pure refactor. | Revert. |
321321+| 5 (resolver protocol) | Low — additive. | Revert. |
322322+| 6 (file split + deprecation removal) | Medium — public API change. | SemVer-major bump means consumers opt in. |
323323+324324+## Order of work, in PR-size chunks
325325+326326+1. **PR 1**: Step 1 — `DPoPSigner` + tests. Standalone, no consumer changes. **DONE**, bundled with PR 2.
327327+2. **PR 2**: Step 2 — wire `DPoPSigner` into `Networking.swift` + `ATProtoOAuth.swift`. Largest diff. Verify against Atprosphere + effem before merge. **DONE**.
328328+3. **PR 3**: Steps 3 + 4 — URL canonicalization + token validator. Small refactor PR. **NEXT**.
329329+4. **PR 4**: Step 5 — resolver protocol + offline tests.
330330+5. **PR 5** (later, behind a SemVer bump): Step 6 — file split and deprecation removal.
331331+332332+Steps 1–5 are non-breaking. Step 6 is breaking and can wait until you have another reason to bump the major version.
333333+334334+## Notes from the Steps 1+2 implementation
335335+336336+- **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`).
337337+- **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.
338338+- **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.
339339+- **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).
340340+- **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`).
341341+- **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.