this repo has no description
2
fork

Configure Feed

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

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.normalized helper, extracted TokenValidator, ATProtoIdentityResolver injection 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. URLResponseProvider interceptor 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.swiftAPRouterDelegate that signs each authenticated XRPC request with a DPoP proof, watches for use_dpop_nonce, refreshes on 401/403, harvests clock skew.
  • APEnvironment.swiftATProtoSession.shared singleton 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:

  • bskyKit re-exports ATProtoOAuth as BskyOAuth (via @_exported import CoreATProtocol).
  • Atprosphere calls ATProtoOAuth(config:storage:) with clientAuthMethod: .privateKeyJWT + authProxyBaseURL.
  • effem calls ATProtoOAuth(config:storage:) with authProxyBaseURL only (default .none).
  • ATProtoCLI does 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#

  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.
  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.
  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.
  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.
  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.

What we should not borrow:

  • AtprotoOAuthAgent/AuthPDSAgent — couples to germ's XRPC abstraction.
  • OAuth.SessionState.Archive — our Login/storage callbacks already do the same job.
  • AtprotoTypes Atproto.DID/Handle/DIDDocument — we have our own types, no need to swap.
  • The AsyncStream save 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 header typ=dpop+jwt, alg=ES256, JWK has kty/crv/x/y.
  • DPoPSignerTests.htuStripsQueryAndFragmenthttps://x/y?z#ahttps://x/y.
  • DPoPSignerTests.athPresentOnlyWhenAccessTokenProvided — round-trip JWT, decode payload, check ath.
  • 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 — set ClockSkewStore offset to +600, sign, decode iat, 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? to ATProtoSession.
  • Update setDPoPPrivateKey(pem:) in CoreATProtocol.swift to instantiate DPoPSigner instead of populating dpopPrivateKey + dpopKeys.
  • Replace APRouterDelegate.generateDPoPProof body with try await signer.sign(.init(httpMethod: method, url: url, accessToken: accessToken)).
  • 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.
  • 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).
  • 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.
  • Keep the existing dpopPrivateKey / dpopKeys accessors on ATProtoSession, 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.swift continues to test DPoPNonceStore for now. The new DPoPSignerTests covers the new path.
  • 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.

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) — ATProtoOAuth class, public config, public errors.
  • OAuth/TokenHandling+ATProto.swift (~250 lines) — buildTokenHandling, authorizationURLProvider, loginProvider, refreshProvider extracted as private extensions on ATProtoOAuth.
  • 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 clientAuthMethod or AuthProxy.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-crypto is already pulled in transitively via jwt-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#

  1. PR 1: Step 1 — DPoPSigner + tests. Standalone, no consumer changes. DONE, bundled with PR 2.
  2. PR 2: Step 2 — wire DPoPSigner into Networking.swift + ATProtoOAuth.swift. Largest diff. Verify against Atprosphere + effem before merge. DONE.
  3. PR 3: Steps 3 + 4 — URL canonicalization + token validator. Small refactor PR. DONE.
  4. PR 4: Step 5 — resolver protocol + offline tests. DONE.
  5. PR 5 (SemVer-major): Step 6 — file split and deprecation removal. DONE.
  6. 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 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).
  • 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.
  • 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.
  • 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).
  • 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).
  • 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.

Notes from the Steps 3+4 implementation#

  • 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.
  • TokenValidator is in its own fileSources/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.
  • 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.
  • 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.
  • 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.

Notes from the Step 5 implementation#

  • Protocol shape matches the existing concrete API. ATProtoIdentityResolver exposes only the two methods ATProtoOAuth actually calls — resolve(identifier:) and isAuthorizationServer(_:validFor:). The concrete IdentityResolver'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-isolated init get murky in Swift 6 (the expression is evaluated relative to the function's isolation, but IdentityResolver() is itself @APActor-isolated). Sidestepped with identityResolver: (any ATProtoIdentityResolver)? = nil and self.identityResolver = identityResolver ?? IdentityResolver() in the body. Same caller ergonomics, no isolation puzzle.
  • ResolvedIdentity stays nested in IdentityResolver. Lifting it to a top-level ATProtoResolvedIdentity would be cleaner but renames a public type. Deferred to Step 6 (which is already a SemVer-major bump). The protocol references IdentityResolver.ResolvedIdentity directly.
  • 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=1 runs 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. A StubIdentityResolver round-trips canned values through both protocol methods, plus one test verifies ATProtoOAuth.init(config:storage:identityResolver:) accepts the injected resolver. End-to-end behavior with the fake can't be tested offline because authenticate(...) 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 existing await ATProtoOAuth(config: ..., storage: ...) and try 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 / dpopNonceStore and the DPoPNonceStore type as the things to delete. While we were already breaking, also deleted the orphan APEnvironment typealias and the ATProtoSession.current accessor (both @available(*, deprecated, renamed:) shims from a previous rename). Confirmed by grep that no consumer references either symbol.
  • DPoPNonceStoreTests deleted; the test file was renamed. The old Tests/CoreATProtocolTests/DPoPStoreTests.swift only had ClockSkewStoreTests left after removing the nonce-store tests, so it became ClockSkewStoreTests.swift. Test count dropped from 75 → 71 (lost 4 tests for the deleted store; gained nothing).
  • File split kept DPoPRequestActor in 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 by refreshLoginIfNeeded, so it stays alongside its only caller in ATProtoOAuth.swift.
  • validateIssuer bumped from private to internal. Cross-file extension access for private doesn't work in Swift — the loginProvider (now in ATProtoOAuth+TokenHandling.swift) needs to call Self.validateIssuer(...), and the source-of-truth method lives in the main file (also called from authenticate and refreshLoginIfNeeded there). internal keeps 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, and URLSession.defaultProvider), TokenModels.swift (73 lines — the three Codable structs). The plan's "~400 lines" estimate for ATProtoOAuth.swift was light; the public types + orchestration logic are unavoidable.
  • Auto-memory restructured to match the rules in the system prompt. The previous MEMORY.md had 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 generic feedback_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 .docc reference noted but not fixed. effem/EffemKit/Sources/EffemKit/EffemKit.docc/Authentication.md still references APEnvironment.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, and APRouterDelegate would need to be initialized with a session reference rather than reading ATProtoSession.shared directly.
  • 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.ResolvedIdentity to a top-level ATProtoResolvedIdentity (renames a public nested type; pairs naturally with the next major bump).
  • The effem EffemKit.docc references stale APEnvironment symbols (see Steps 6+7 notes).
  • Add a Slingshot/community DoH fallback resolver — the ATProtoIdentityResolver seam exists for exactly this; not built yet because nobody needs it.
  • Consider extracting authenticate and refreshLoginIfNeeded into their own extension file. They're ~200 lines together; the main file would shrink to ~520. Pure organizational; only worth doing if ATProtoOAuth.swift starts feeling unwieldy again.