OAuth update plan for CoreATProtocol#
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.
Status (2026-04-29)#
The plan is fully landed. Steps 1–7 are all done.
- Per-origin DPoP nonce caching, shared
URLOrigin.normalizedhelper, extractedTokenValidator,ATProtoIdentityResolverinjection seam, file split, and deprecated-symbol removal are all live. - CoreATProtocol has 71 tests (4 live-network ones opt-in via
CORE_ATPROTOCOL_LIVE_TESTS=1); the deterministic suite runs in ~50ms. - bskyKit and EffemKit rebuild cleanly against the local checkout. Atprosphere + effem-iOS verified by source inspection — no consumer call sites broke.
- Auto-memory updated: stale Plume entries dropped, four new memory files added covering the per-origin nonce design, consumer OAuth modes, the singleton testing constraint, and the live-tests env var.
The only obvious next milestone is unrelated to this plan: make ATProtoSession instance-based. See "What didn't make this plan" near the bottom.
See the "Notes from..." sections near the bottom for deviations from the original plan at each step.
Inventory of what we have today#
Files (in Sources/CoreATProtocol/):
OAuth/ATProtoOAuth.swift— 1016 lines. Authorize / refresh / DPoP JWT generation / token persistence. Single class doing everything.OAuth/AuthProxy.swift— 294 lines.URLResponseProviderinterceptor that re-routes PAR + token requests through an auth proxy, with bypass on transport failure.OAuth/IdentityResolver.swift— 366 lines. Handle ↔ DID ↔ PDS ↔ auth server resolution + bidirectional handle verification.Networking.swift—APRouterDelegatethat signs each authenticated XRPC request with a DPoP proof, watches foruse_dpop_nonce, refreshes on 401/403, harvests clock skew.APEnvironment.swift—ATProtoSession.sharedsingleton holding tokens + DPoP key + JWT key collection + nonce store + clock skew store.DPoPNonceStore.swift— single-slot actor for the most recent server nonce.ClockSkewStore.swift— observed offset between local and server clocks.TokenRefreshCoordinator.swift— coalesces concurrent refresh attempts.Models/—Session,AtError, etc.
Consumers:
bskyKitre-exportsATProtoOAuthasBskyOAuth(via@_exported import CoreATProtocol).AtprospherecallsATProtoOAuth(config:storage:)withclientAuthMethod: .privateKeyJWT+authProxyBaseURL.effemcallsATProtoOAuth(config:storage:)withauthProxyBaseURLonly (default.none).ATProtoCLIdoes not use OAuth.
Tests: 1012 lines across 9 files, including OAuthTests.swift (446 lines) covering proxy request encoding, key ID storage, DPoP key persistence, and identity resolution.
What's worth borrowing from AtprotoOAuth#
- Per-origin DPoP nonce cache. AtprotoOAuth's
OAuth.DPoP.State.nonceCacheis 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 spurioususe_dpop_nonceretries when the most recent nonce is from the wrong origin. - Consolidated DPoP signer type. AtprotoOAuth bundles
signingKey+nonceCache+decoderinto one value (OAuth.DPoP.State). We have these scattered acrossATProtoSession(dpopPrivateKey,dpopKeys,dpopNonceStore) and re-thread them throughNetworking.swiftandATProtoOAuth.swift. A singleDPoPSigneractor would hold the lot. htucanonicalization helper. We strip query+fragment in two places (Networking.swiftline 92-96 andATProtoOAuth.swiftline 561). One small free function.- Pluggable resolver protocol. Right now
IdentityResolveris concrete. AtprotoOAuth hasAtproto.Resolveras a protocol with a defaultverifiedResolveand aFallbackResolverthat tries primary then secondary. Useful for tests (inject a fake resolver) and for adding Slingshot/community DoH fallback later. - 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.
What we should not borrow:
AtprotoOAuthAgent/AuthPDSAgent— couples to germ's XRPC abstraction.OAuth.SessionState.Archive— ourLogin/storagecallbacks already do the same job.- AtprotoTypes
Atproto.DID/Handle/DIDDocument— we have our own types, no need to swap. - The
AsyncStreamsave model — a callback closure (what we have) is simpler and equally correct.
Plan#
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.
Step 1 — Add DPoPSigner (the consolidated signer actor) — DONE#
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.
Create Sources/CoreATProtocol/OAuth/DPoPSigner.swift:
import Foundation
import Crypto
import JWTKit
/// Owns the DPoP signing key, the per-origin nonce cache, and JWT generation.
///
/// One `DPoPSigner` per logical session. Nonces are keyed by origin
/// (`scheme://host[:port]`) per RFC 9449 — the auth server and the PDS have
/// independent nonce streams, and using the wrong one causes a spurious
/// `use_dpop_nonce` challenge on the next request.
public actor DPoPSigner {
public struct ProofParameters: Sendable {
public let httpMethod: String
public let url: URL
/// Access token to bind via `ath`. Pass `nil` for unauthenticated
/// requests (PAR, token exchange).
public let accessToken: String?
}
private let privateKey: ES256PrivateKey
private let keys: JWTKeyCollection
private var noncesByOrigin: [String: String] = [:]
private let nonceCacheLimit = 25
private let clockSkew: ClockSkewStore
public init(privateKey: ES256PrivateKey, clockSkew: ClockSkewStore) async {
self.privateKey = privateKey
let keys = JWTKeyCollection()
await keys.add(ecdsa: privateKey)
self.keys = keys
self.clockSkew = clockSkew
}
public convenience init(clockSkew: ClockSkewStore) async {
await self.init(privateKey: ES256PrivateKey(), clockSkew: clockSkew)
}
public var pemRepresentation: String { privateKey.pemRepresentation }
public func sign(_ params: ProofParameters) async throws -> String {
let htu = Self.canonicalHTU(params.url)
let origin = Self.origin(for: params.url)
let nonce = origin.flatMap { noncesByOrigin[$0] }
let issuedAt = await clockSkew.serverAdjustedNow()
let ath = params.accessToken.map(Self.athClaim)
var header = JWTHeader()
header.typ = "dpop+jwt"
header.alg = "ES256"
if let p = privateKey.parameters {
header.jwk = [
"kty": .string("EC"), "crv": .string("P-256"),
"x": .string(p.x.base64URLEncoded()),
"y": .string(p.y.base64URLEncoded()),
]
}
let payload = DPoPProofPayload(
htm: params.httpMethod, htu: htu,
iat: .init(value: issuedAt),
jti: .init(value: UUID().uuidString),
nonce: nonce, ath: ath
)
return try await keys.sign(payload, header: header)
}
/// Records a nonce against the origin of `url`, evicting the oldest entry
/// if the cache exceeds its limit.
public func cacheNonce(_ nonce: String, from url: URL) {
guard let origin = Self.origin(for: url) else { return }
if noncesByOrigin.count >= nonceCacheLimit, noncesByOrigin[origin] == nil {
// Drop one arbitrary entry. A true LRU is overkill — bounded growth is the goal.
if let key = noncesByOrigin.keys.first { noncesByOrigin.removeValue(forKey: key) }
}
noncesByOrigin[origin] = nonce
}
public func clearNonces() { noncesByOrigin.removeAll() }
// MARK: - Helpers
static func canonicalHTU(_ url: URL) -> String {
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.query = nil
components?.fragment = nil
return components?.url?.absoluteString ?? url.absoluteString
}
static func origin(for url: URL) -> String? {
guard let scheme = url.scheme?.lowercased(),
let host = url.host?.lowercased() else { return nil }
let port = url.port.map { ":\($0)" } ?? ""
return "\(scheme)://\(host)\(port)"
}
static func athClaim(for accessToken: String) -> String {
let hash = SHA256.hash(data: Data(accessToken.utf8))
return Data(hash).base64URLEncodedString()
}
}
private struct DPoPProofPayload: JWTPayload {
let htm: String
let htu: String
let iat: IssuedAtClaim
let jti: IDClaim
let nonce: String?
let ath: String?
func verify(using key: some JWTAlgorithm) throws {}
}
Tests to add (Swift Testing):
DPoPSignerTests.signEmitsDpopJwtType— verify headertyp=dpop+jwt,alg=ES256, JWK haskty/crv/x/y.DPoPSignerTests.htuStripsQueryAndFragment—https://x/y?z#a→https://x/y.DPoPSignerTests.athPresentOnlyWhenAccessTokenProvided— round-trip JWT, decode payload, checkath.DPoPSignerTests.nonceCachedPerOrigin— cache nonces for two origins, verify each origin gets its own nonce in the next signed proof.DPoPSignerTests.nonceCacheBounded— push 30 origins, assert ≤ 25 entries.DPoPSignerTests.iatRespectsClockSkew— setClockSkewStoreoffset to +600, sign, decodeiat, assert ≈ now + 600.
Don't wire it into Networking.swift or ATProtoOAuth.swift yet — that's Step 2.
Step 2 — Replace ad-hoc DPoP signing in Networking.swift and ATProtoOAuth.swift — DONE#
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.
Changes:
- Add
var dpopSigner: DPoPSigner?toATProtoSession. - Update
setDPoPPrivateKey(pem:)inCoreATProtocol.swiftto instantiateDPoPSignerinstead of populatingdpopPrivateKey+dpopKeys. - Replace
APRouterDelegate.generateDPoPProofbody withtry await signer.sign(.init(httpMethod: method, url: url, accessToken: accessToken)). - Replace
APRouterDelegate.didReceiveErrorResponse'sdpopNonceStore.update(headerNonce)withsigner.cacheNonce(headerNonce, from: response.url ?? request.url). The response URL is the right origin to key on. - In
ATProtoOAuth.generateJWT(the auth-server-side signer at line 528), build aDPoPSigner.ProofParametersfromparams.requestEndpoint+params.httpMethod+nilaccess token. Delete the localstripQueryAndFragmenthelper (line 561). - Keep
DPoPNonceStorefor now — it backs the deprecateddpopNonceStoreaccessor on the session. Mark it@available(*, deprecated, message: "Use DPoPSigner.cacheNonce instead.")and migrate callers in Step 6. - Keep the existing
dpopPrivateKey/dpopKeysaccessors onATProtoSession, but mark them deprecated. They're public, so removing them is a major-version change.
After this, Networking.swift shrinks by ~50 lines. The whole DPoP proof construction lives in one file.
Tests to update:
DPoPStoreTests.swiftcontinues to testDPoPNonceStorefor now. The newDPoPSignerTestscovers the new path.- Add an integration test in
OAuthTests.swiftthat drives a fakeURLResponseProvider, sees aDPoP-Nonceheader, and confirms the next outbound proof carries the nonce.
Step 3 — Centralize URL canonicalization + origin helpers — DONE#
The htu and origin logic now lives in DPoPSigner (steps 1–2). Two more places use it:
ATProtoOAuth.normalizedOrigin(_:)(line 901) — for issuer comparison.IdentityResolver.normalizedOrigin(from:)(line 287) — for auth-server-vs-PDS check.
Both are private and trivially identical. Lift them into a small file Sources/CoreATProtocol/OAuth/URLOrigin.swift:
enum URLOrigin {
static func normalized(_ url: URL) -> String? {
guard let scheme = url.scheme?.lowercased(),
let host = url.host?.lowercased() else { return nil }
let port = url.port.map { ":\($0)" } ?? ""
return "\(scheme)://\(host)\(port)"
}
}
Update three call sites (DPoPSigner.origin, ATProtoOAuth.normalizedOrigin, IdentityResolver.normalizedOrigin) to call URLOrigin.normalized. Internal helper, no public API change.
Step 4 — Extract the token validator closure — DONE#
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).
Extract to a private helper:
private struct TokenValidator: Sendable {
let expectedAuthorizationServer: String
let expectedSubjectDID: String?
func validate(
response: OAuthTokenResponse,
actualIssuer: String
) throws {
guard response.tokenType == "DPoP" else {
throw AuthenticatorError.dpopTokenExpected(response.tokenType)
}
guard response.scopes.contains("atproto") else {
throw ATProtoOAuthError.missingRequiredScope("atproto")
}
try ATProtoOAuth.validateIssuer(actualIssuer, matches: expectedAuthorizationServer)
if let expectedSubjectDID, response.subject != expectedSubjectDID {
throw ATProtoOAuthError.subjectMismatch(
expected: expectedSubjectDID,
actual: response.subject
)
}
}
}
Both providers call validator.validate(response:actualIssuer:). Worth ~30 lines saved and the validator becomes directly unit-testable.
Tests: add TokenValidatorTests covering the four failure modes (wrong token type, missing scope, issuer mismatch, sub mismatch) and the success path.
Step 5 — Make IdentityResolver a protocol — DONE#
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.
Introduce:
public protocol ATProtoIdentityResolver: Sendable {
func resolve(identifier: String) async throws -> IdentityResolver.ResolvedIdentity
func isAuthorizationServer(_ authorizationServer: String, validFor pdsEndpoint: String) async throws -> Bool
}
extension IdentityResolver: ATProtoIdentityResolver {}
Add an injection point on ATProtoOAuth.init:
public init(
config: ATProtoOAuthConfig,
storage: ATProtoAuthStorage,
identityResolver: ATProtoIdentityResolver = IdentityResolver()
) async { ... }
Don't add a "fallback resolver" yet — YAGNI until you actually need a Slingshot/community DoH backup. Just open the seam.
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.
Step 6 — Optional: split ATProtoOAuth.swift along seams — DONE#
Only do this if Steps 1–5 still leave the file feeling unwieldy. The seams are clear:
ATProtoOAuth.swift(~400 lines) —ATProtoOAuthclass, public config, public errors.OAuth/TokenHandling+ATProto.swift(~250 lines) —buildTokenHandling,authorizationURLProvider,loginProvider,refreshProviderextracted as private extensions onATProtoOAuth.OAuth/TokenModels.swift(~150 lines) —OAuthTokenRequest,OAuthRefreshTokenRequest,OAuthTokenResponse.
Don't change behavior in this step — pure file move + access-control tightening.
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.
Step 7 — Update auto-memory — DONE#
After all of this lands, update ~/.claude/projects/-Users-rademaker-Developer-SparrowTek-AtProto/memory/MEMORY.md:
- Note that DPoP signing is consolidated in
OAuth/DPoPSigner.swift. - Note the per-origin nonce cache (so future-Claude doesn't try to re-introduce a single-slot store).
- Note that Atprosphere uses
clientAuthMethod: .privateKeyJWT, effem uses.none, and the auth proxy path is exercised in production by both. - Drop the stale Plume entries — that directory no longer exists in this checkout.
What we're explicitly NOT doing#
- 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. - Not removing
clientAuthMethodorAuthProxy.swift. Atprosphere's production OAuth depends on the confidential-client + auth-proxy path. - 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.
- Not introducing new dependencies.
swift-cryptois already pulled in transitively viajwt-kit; everything else is Foundation.
Risk + rollback#
| Step | Risk | Rollback |
|---|---|---|
| 1 (add DPoPSigner) | Low — new file, not wired in. | Delete the file. |
| 2 (wire in DPoPSigner) | Medium — touches every authenticated request. | Revert; deprecated accessors still work. |
| 3 (URL canonicalization) | Low — pure refactor. | Revert. |
| 4 (extract validator) | Low — pure refactor. | Revert. |
| 5 (resolver protocol) | Low — additive. | Revert. |
| 6 (file split + deprecation removal) | Medium — public API change. | SemVer-major bump means consumers opt in. |
Order of work, in PR-size chunks#
- PR 1: Step 1 —
DPoPSigner+ tests. Standalone, no consumer changes. DONE, bundled with PR 2. - PR 2: Step 2 — wire
DPoPSignerintoNetworking.swift+ATProtoOAuth.swift. Largest diff. Verify against Atprosphere + effem before merge. DONE. - PR 3: Steps 3 + 4 — URL canonicalization + token validator. Small refactor PR. DONE.
- PR 4: Step 5 — resolver protocol + offline tests. DONE.
- PR 5 (SemVer-major): Step 6 — file split and deprecation removal. DONE.
- PR 6 (housekeeping): Step 7 — auto-memory refresh. DONE.
Steps 1–5 are non-breaking. Step 6 is breaking and can wait until you have another reason to bump the major version.
Notes from the Steps 1+2 implementation#
- Renamed the new actor to
DPoPProofSigner. OAuthenticator already exportspublic final class DPoPSigner(itsJWTGeneratortypealias is the entry point we hand toTokenHandling), so the plan's name would have collided. The new file isSources/CoreATProtocol/OAuth/DPoPProofSigner.swift; everywhere the plan saysDPoPSignerfor our new actor, the code saysDPoPProofSigner. OAuthenticator'sDPoPSignerkeeps its name and role (per-flow nonce holder forAuthenticator). - Added an
explicitNonceparameter toProofParameters. The auth-server flow runs throughOAuthenticator.DPoPSigner, which already tracks the auth-server nonce for the exchange and passes it viaJWTParameters.nonce. We forward that asexplicitNonceso the call doesn't consult the per-origin cache (which is the XRPC layer's). The XRPC layer inNetworking.swiftcallssign(_:)without anexplicitNonceand the cache is consulted normally. - Did not deprecate
dpopPrivateKey/dpopKeys/dpopNonceStoreyet. The plan calls for@available(*, deprecated, ...)on those public properties, but applying it now would require@available-aware suppression at the write sites insetDPoPPrivateKey(pem:). Cleaner to defer until Step 6, which removes them outright. The newdpopProofSigneraccessor is the production read path; the legacy fields are dual-written insetDPoPPrivateKey(pem:)purely for source compatibility with external readers. - Dropped the integration test from Step 2. The plan suggested an end-to-end test that drives
APRouterDelegate.didReceiveErrorResponsetheninterceptand verifies the cached nonce flows through. Implementation revealed that any test touchingATProtoSession.sharedraces with other suites that callawait ATProtoSession.shared.reset()(NonceDetectionTests,RefreshLoginTests,DPoPTests). Swift Testing's.serializedtrait 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 inAPEnvironment.swift's "future major release will make it instance-based" comment. Until then, the per-origin caching is fully covered by the unit tests inDPoPProofSignerTests, and the wiring throughNetworking.swiftis straightforward delegation (the kind of code production traffic exercises immediately). - 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.xcodeprojconsumers; their call sites were inspected and only touch preserved public APIs (setDPoPPrivateKey(pem:),ATProtoOAuth(config:storage:),ATProtoOAuthConfig,ATProtoOAuthError,ATProtoSession.shared.host,ATProtoSession.shared.routerDelegate). - Updated test:
nonceCacheBoundedwas 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.
Notes from the Steps 3+4 implementation#
- Deleted the
normalizedOriginwrappers entirely rather than having them delegate.DPoPProofSigner.origin(for:),ATProtoOAuth.normalizedOrigin(_:), andIdentityResolver.normalizedOrigin(from:)are gone; their five call sites now invokeURLOrigin.normalizeddirectly. Wrappers that only forward to a one-liner are noise. TokenValidatoris in its own file —Sources/CoreATProtocol/OAuth/TokenValidator.swift. The plan placed it as a private nested type inATProtoOAuth.swift, but the file is already 970+ lines and the validator is unit-tested directly (TokenValidatorTests), so a separate file is cleaner. The struct isinternal(notprivate) so tests reach it via@testable import CoreATProtocol.OAuthTokenResponsewas bumped fromprivatetointernal(andSendable). Tests construct it via the synthesized memberwise init — much simpler than building JSON and round-tripping throughCodable.OAuthTokenRequestandOAuthRefreshTokenRequeststayprivate; nothing outsideATProtoOAuth.swiftneeds them.- Issuer validation stays inline in
loginProvider, not inTokenValidator. The plan implied both providers shared an issuer check, but they don't: the auth-callback flow validates theissquery parameter (which the validator never sees), and the refresh flow has noissto 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 twovalidator.validate(_:)calls. - Tests added:
TokenValidatorTestscovers the success path, wrong token type, missingatprotoscope, 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.
Notes from the Step 5 implementation#
- Protocol shape matches the existing concrete API.
ATProtoIdentityResolverexposes only the two methodsATProtoOAuthactually calls —resolve(identifier:)andisAuthorizationServer(_:validFor:). The concreteIdentityResolver's extras (resolve(handle:),resolve(did:)) stay off the protocol; tests that need them keep using the concrete type. - Optional parameter, not an autoclosure default. Plan's signature was
identityResolver: ATProtoIdentityResolver = IdentityResolver(). Default expressions on a global-actor-isolatedinitget murky in Swift 6 (the expression is evaluated relative to the function's isolation, butIdentityResolver()is itself@APActor-isolated). Sidestepped withidentityResolver: (any ATProtoIdentityResolver)? = nilandself.identityResolver = identityResolver ?? IdentityResolver()in the body. Same caller ergonomics, no isolation puzzle. ResolvedIdentitystays nested inIdentityResolver. Lifting it to a top-levelATProtoResolvedIdentitywould be cleaner but renames a public type. Deferred to Step 6 (which is already a SemVer-major bump). The protocol referencesIdentityResolver.ResolvedIdentitydirectly.- Live tests are opt-in via env var, not removed. Plan suggested
.disabled(if:)or a tag. Used a single env-var gate:CORE_ATPROTOCOL_LIVE_TESTS=1runs the four live network tests (testResolveHandle,testHandleCleaning,testServerMetadataLoad,testClientMetadataLoad); without it they're skipped and the deterministic suite stays at ~80ms. The smoke-test value of those tests is preserved for local debugging without polluting CI. - Three new offline tests in
IdentityResolverProtocolTests. AStubIdentityResolverround-trips canned values through both protocol methods, plus one test verifiesATProtoOAuth.init(config:storage:identityResolver:)accepts the injected resolver. End-to-end behavior with the fake can't be tested offline becauseauthenticate(...)still goes to live network for client/server metadata — that's a future seam. - No consumer changes. The new
identityResolver:parameter has a default, so all existingawait ATProtoOAuth(config: ..., storage: ...)andtry await ATProtoOAuth(config: ..., storage: ..., privateKeyPEM: ...)call sites compile unchanged. bskyKit, EffemKit, Atprosphere, and effem-iOS all good.
Notes from the Steps 6+7 implementation#
- Removed more than just the DPoP-deprecated surface. Plan Step 6 listed
ATProtoSession.dpopPrivateKey/dpopKeys/dpopNonceStoreand theDPoPNonceStoretype as the things to delete. While we were already breaking, also deleted the orphanAPEnvironmenttypealias and theATProtoSession.currentaccessor (both@available(*, deprecated, renamed:)shims from a previous rename). Confirmed by grep that no consumer references either symbol. DPoPNonceStoreTestsdeleted; the test file was renamed. The oldTests/CoreATProtocolTests/DPoPStoreTests.swiftonly hadClockSkewStoreTestsleft after removing the nonce-store tests, so it becameClockSkewStoreTests.swift. Test count dropped from 75 → 71 (lost 4 tests for the deleted store; gained nothing).- File split kept
DPoPRequestActorin the main file. Plan didn't specify where to put it; moving it to the extension file would have required bumping its access level. It's a small infra type used byrefreshLoginIfNeeded, so it stays alongside its only caller inATProtoOAuth.swift. validateIssuerbumped fromprivatetointernal. Cross-file extension access forprivatedoesn't work in Swift — theloginProvider(now inATProtoOAuth+TokenHandling.swift) needs to callSelf.validateIssuer(...), and the source-of-truth method lives in the main file (also called fromauthenticateandrefreshLoginIfNeededthere).internalkeeps it module-scoped.- Final layout:
ATProtoOAuth.swift(720 lines — public types + class + auth orchestration +DPoPRequestActor+validateIssuer),ATProtoOAuth+TokenHandling.swift(186 lines —buildTokenHandling, the three providers,decodeTokenResponse, andURLSession.defaultProvider),TokenModels.swift(73 lines — the three Codable structs). The plan's "~400 lines" estimate forATProtoOAuth.swiftwas light; the public types + orchestration logic are unavoidable. - Auto-memory restructured to match the rules in the system prompt. The previous
MEMORY.mdhad inline content (the auto-memory rules require it to be a flat index pointing at typed memory files). Dropped six stale Plume-app entries (Plume isn't in this checkout), kept the genericfeedback_review_struct_vs_class.md, added four new files:feedback_dpop_per_origin_nonce.md,project_consumer_oauth_modes.md,feedback_atproto_session_singleton.md,reference_live_tests_env_var.md. - Stale
.doccreference noted but not fixed.effem/EffemKit/Sources/EffemKit/EffemKit.docc/Authentication.mdstill referencesAPEnvironment.current.dpopPrivateKey(an API that's been gone for a while now). It's documentation, not code, so the build is fine. Worth a follow-up cleanup in the effem repo when someone touches that doc file.
What didn't make this plan#
The most obvious payoff still on the table is making ATProtoSession instance-based (already flagged in APEnvironment.swift's docstring as "a future major release"):
- The singleton is what blocked the integration test from Step 2 (cross-suite
reset()calls clobber state). - A scoped session would make the routing layer testable end-to-end without env-var gates.
- Consumers would need updates:
setup(),updateTokens(),setDPoPPrivateKey(pem:),setDelegate(),setTokenRefreshHandler(),update(hostURL:)would all become methods on a session instance, andAPRouterDelegatewould need to be initialized with a session reference rather than readingATProtoSession.shareddirectly. - It's a bigger lift than any single step in this plan — touches every consumer and the entire networking layer.
Other smaller follow-ups:
- Lift
IdentityResolver.ResolvedIdentityto a top-levelATProtoResolvedIdentity(renames a public nested type; pairs naturally with the next major bump). - The
effemEffemKit.doccreferences staleAPEnvironmentsymbols (see Steps 6+7 notes). - Add a Slingshot/community DoH fallback resolver — the
ATProtoIdentityResolverseam exists for exactly this; not built yet because nobody needs it. - Consider extracting
authenticateandrefreshLoginIfNeededinto their own extension file. They're ~200 lines together; the main file would shrink to ~520. Pure organizational; only worth doing ifATProtoOAuth.swiftstarts feeling unwieldy again.