···11+//
22+// TokenValidator.swift
33+// CoreATProtocol
44+//
55+66+import Foundation
77+import OAuthenticator
88+99+/// Validates the response payload from the AT Protocol token endpoint
1010+/// against the requirements that apply to both initial login and refresh:
1111+/// the token type must be `DPoP`, the `atproto` scope must be granted, and
1212+/// — when the caller knows which DID they expected — the response's `sub`
1313+/// must match.
1414+///
1515+/// Issuer matching is *not* the validator's job. The login flow validates
1616+/// the `iss` query parameter from the authorization callback (which the
1717+/// validator never sees), and the refresh flow has no `iss` to validate
1818+/// against because the auth-server URL is fixed by the time refresh runs.
1919+struct TokenValidator: Sendable {
2020+ let expectedSubjectDID: String?
2121+2222+ func validate(_ response: OAuthTokenResponse) throws {
2323+ guard response.tokenType == "DPoP" else {
2424+ throw AuthenticatorError.dpopTokenExpected(response.tokenType)
2525+ }
2626+ guard response.scopes.contains("atproto") else {
2727+ throw ATProtoOAuthError.missingRequiredScope("atproto")
2828+ }
2929+ if let expectedSubjectDID, response.subject != expectedSubjectDID {
3030+ throw ATProtoOAuthError.subjectMismatch(
3131+ expected: expectedSubjectDID,
3232+ actual: response.subject
3333+ )
3434+ }
3535+ }
3636+}
+25
Sources/CoreATProtocol/OAuth/URLOrigin.swift
···11+//
22+// URLOrigin.swift
33+// CoreATProtocol
44+//
55+66+import Foundation
77+88+/// Single source of truth for the "origin" representation used by the OAuth
99+/// layer to compare URLs that should be considered equivalent endpoints
1010+/// (issuer matching, per-origin nonce caching, auth-server validation).
1111+///
1212+/// The shape — `scheme://host[:port]` with both lowercased — matches RFC 6454
1313+/// and what AT Protocol metadata documents declare. Any divergence between
1414+/// call sites would silently misclassify equivalent endpoints, so all
1515+/// callers go through this one helper.
1616+enum URLOrigin {
1717+ static func normalized(_ url: URL) -> String? {
1818+ guard let scheme = url.scheme?.lowercased(),
1919+ let host = url.host?.lowercased() else {
2020+ return nil
2121+ }
2222+ let port = url.port.map { ":\($0)" } ?? ""
2323+ return "\(scheme)://\(host)\(port)"
2424+ }
2525+}
···4455## Status (2026-04-29)
6677-- **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.
77+- **Steps 1–4 are landed.** Per-origin DPoP nonce caching, the shared `URLOrigin.normalized` helper, and the extracted `TokenValidator` are all live. CoreATProtocol's 72 tests pass; bskyKit and EffemKit rebuild cleanly against the local checkout.
88+- **Step 5 is next.** Resolver protocol + offline tests; additive, low risk.
99- **Step 6 remains gated** behind a SemVer-major bump.
10101111-See "Notes from the Steps 1+2 implementation" near the bottom for the deviations from the original plan.
1111+See "Notes from the Steps 1+2 implementation" and "Notes from the Steps 3+4 implementation" near the bottom for deviations from the original plan.
12121313## Inventory of what we have today
1414···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.
199199200200-### Step 3 — Centralize URL canonicalization + origin helpers
200200+### Step 3 — Centralize URL canonicalization + origin helpers — DONE
201201202202The `htu` and origin logic now lives in `DPoPSigner` (steps 1–2). Two more places use it:
203203- `ATProtoOAuth.normalizedOrigin(_:)` (line 901) — for issuer comparison.
···218218219219Update three call sites (`DPoPSigner.origin`, `ATProtoOAuth.normalizedOrigin`, `IdentityResolver.normalizedOrigin`) to call `URLOrigin.normalized`. Internal helper, no public API change.
220220221221-### Step 4 — Extract the token validator closure
221221+### Step 4 — Extract the token validator closure — DONE
222222223223`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···3253253263261. **PR 1**: Step 1 — `DPoPSigner` + tests. Standalone, no consumer changes. **DONE**, bundled with PR 2.
3273272. **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.
328328+3. **PR 3**: Steps 3 + 4 — URL canonicalization + token validator. Small refactor PR. **DONE**.
329329+4. **PR 4**: Step 5 — resolver protocol + offline tests. **NEXT**.
3303305. **PR 5** (later, behind a SemVer bump): Step 6 — file split and deprecation removal.
331331332332Steps 1–5 are non-breaking. Step 6 is breaking and can wait until you have another reason to bump the major version.
···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.
342342+343343+## Notes from the Steps 3+4 implementation
344344+345345+- **Deleted the `normalizedOrigin` wrappers entirely** rather than having them delegate. `DPoPProofSigner.origin(for:)`, `ATProtoOAuth.normalizedOrigin(_:)`, and `IdentityResolver.normalizedOrigin(from:)` are gone; their five call sites now invoke `URLOrigin.normalized` directly. Wrappers that only forward to a one-liner are noise.
346346+- **`TokenValidator` is in its own file** — `Sources/CoreATProtocol/OAuth/TokenValidator.swift`. The plan placed it as a private nested type in `ATProtoOAuth.swift`, but the file is already 970+ lines and the validator is unit-tested directly (`TokenValidatorTests`), so a separate file is cleaner. The struct is `internal` (not `private`) so tests reach it via `@testable import CoreATProtocol`.
347347+- **`OAuthTokenResponse` was bumped from `private` to `internal` (and `Sendable`).** Tests construct it via the synthesized memberwise init — much simpler than building JSON and round-tripping through `Codable`. `OAuthTokenRequest` and `OAuthRefreshTokenRequest` stay `private`; nothing outside `ATProtoOAuth.swift` needs them.
348348+- **Issuer validation stays inline in `loginProvider`, not in `TokenValidator`.** The plan implied both providers shared an issuer check, but they don't: the auth-callback flow validates the `iss` query parameter (which the validator never sees), and the refresh flow has no `iss` to validate against — the auth-server URL is fixed by the time refresh runs. The validator handles only the response-payload checks (token type, scope, sub) that genuinely duplicated. Net change: ~10 lines of duplicated guards collapsed to two `validator.validate(_:)` calls.
349349+- **Tests added:** `TokenValidatorTests` covers the success path, wrong token type, missing `atproto` scope, sub mismatch, sub passthrough when expected DID is nil, and a multi-scope success case. Six tests, all sub-millisecond. Total suite is now 72 tests.