feat(auth)!: unified session model and a reliable session lifecycle #158
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!158
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-124-session"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #124.
One session model, one persistence abstraction, and a session lifecycle that refreshes, persists and signs out correctly, including under concurrency.
Model
abstract record AtProtoSession(Did,Handle,ServiceEndpoint,ExpiresAt) withPasswordSession(access/refresh JWT, email, status) andOAuthSession(DPoP-bound tokens,DPoPKeyas PKCS#8 bytes,Issuer,TokenEndpoint,RevocationEndpoint,Scope). Immutable;ExpiresAtreplacesExpiresIn+ obtained-at. Serializes as the base type with a$kinddiscriminator;ToString()never prints tokens.AtProtoClient.Sessionis the single property (OAuthSessionproperty gone), plusevent SessionChanged(Created/Refreshed/Expired/Removed, with previous session and, forExpired, the error).IAtProtoSessionStore(GetAsync(Did),SetAsync(session),RemoveAsync(Did)) replacesISessionStore(never read by the SDK) andIAtProtoTokenStore. Implementations:InMemoryAtProtoSessionStore(moved to core),FileAtProtoSessionStore,EfCoreAtProtoSessionStore<T>. The client writes every session it installs or refreshes and removes it on sign-out/expiry. The three session↔token-data mappings (debloat 9.14) are gone.AtProtoTokenstable andAtProtoTokenEntity; the encrypted JSON blob now holds the session. The file and EF stores read rows/files the 0.6 token stores wrote (pinned by tests that serialize the exact 0.6AtProtoTokenDatashape with the 0.6 options), so upgraded deployments stay signed in.AtProtoClient:LoginAsync(now withallowTakendown, the Lexicon flag #123 added to the internal request, also onServerClient.CreateSessionAsync; sent only when true),CreateAccountAndLoginAsync(new),ApplySessionAsync(session, oauthClient?)(replacesApplyOAuthSessionAsync),ResumeSessionAsync(session, oauthClient?)(validates withgetSession),TryRestoreSessionAsync(did, oauthClient?)(new; reads the store),RefreshSessionAsync,LogoutAsync.OAuthClient:CompleteAuthorizationAsyncreturnsOAuthSession;RefreshAsync(session)replacesRefreshTokensAsync; newRevokeAsync(session).Refresh (the in-pipeline "auth handler")
Implemented inside
XrpcClient's pipeline rather than as aDelegatingHandler(the SDK never owns theHttpClient's handler chain): an internalIXrpcSessionHandlerthatAtProtoClient's internalSessionManagerimplements.exp(no more fixed 115 min); OAuth fromexpires_in. A transient proactive failure still sends the request while the token is valid.ExpiredToken(400/401) or a 401 withWWW-Authenticate: … error="invalid_token"(RFC 6750/9449), refresh and resend once.XrpcAuthenticationExceptionfromrefreshSession, OAuthinvalid_grant) ends the session: removed locally and from the store,SessionChanged(Expired), error rethrown. Transient failures keep the session.AtProtoClientOptions.BackgroundRefresh/WithBackgroundRefresh(default off).AutoRefreshSessionnow means on-demand refresh.AtProtoClientFactory) now refresh on demand and write rotated tokens back to the store (previously they never refreshed). The per-DID cross-client coordinator remains #136.Bugs fixed
deleteSessionnow gets the refresh JWT; an OAuth session is revoked at the ASrevocation_endpoint(RFC 7009, DPoP proof,use_dpop_nonceretry; endpoint recorded at authorization, discovered from AS metadata otherwise). Teardown is local-first (memory + store), then remote; failures are rethrown afterwards. A token the PDS already considers invalid is not a failure. Blazor/atproto/logoutrevokes too.PostWithDPoPAsyncthat checks the status, parses the OAuth error (bounded read), retries once onuse_dpop_nonce, remembers AS nonces per origin, and disposes responses. A 2xx without an access token (or non-DPoP token type) is rejected.ResumeSessionAsyncon a fresh client: installs first, thengetSessionruns through the pipeline, so an expired token is refreshed and retried.ServerClientno longer callsSetTokens/ClearTokens;RefreshSessionAsync(refreshJwt)/DeleteSessionAsync(refreshJwt)take the token;createSession/createAccountare sent without the installed session's credentials.PdsAdminClientsets its admin session explicitly.DisposeAsyncis primary (awaits an in-flight exchange and its store write); syncDisposereturns at once, lets the exchange land in the background and releases the DPoP key afterwards (was up to 35 s blocking). Ownership: the client disposes only what it creates; sessions are values, and the client builds/disposes its ownDPoPProofGeneratorfromDPoPKey. Thread-safety and ownership contract written up indocs/session-management.md.Breaking changes (migration)
Session,OAuthSessionResult,AtProtoTokenData→AtProtoSession/PasswordSession/OAuthSession. Pattern-matchclient.Session; unverified handle ishandle.invalid;DidDocdropped (useServiceEndpoint).ApplyOAuthSessionAsync(result, oauth, store)→ApplySessionAsync(session, oauth)on a client builtWithSessionStore(store);ResumeSessionAsynctakesAtProtoSession(+ optionalOAuthClient) and returns the session.RefreshTokensAsync→OAuthClient.RefreshAsync(session)(returns a new session); nousingon theCompleteAuthorizationAsyncresult.ISessionStore/IAtProtoTokenStore→IAtProtoSessionStore; store classes renamed (InMemoryAtProtoSessionStorein core,FileAtProtoSessionStore+FileSessionStoreOptions,EfCoreAtProtoSessionStore<T>+AddAtProtoEfCoreSessionStore<T>());AtProtoClientctor,WithSessionStore,AddAtProto<T>,AddAtProtoServer<T>,AtProtoClientFactorytake the new interface. No EF migration needed.ServerClient.RefreshSessionAsync(refreshJwt)/DeleteSessionAsync(refreshJwt);CreateSessionAsync/CreateAccountAsyncno longer install tokens → useLoginAsync/CreateAccountAndLoginAsync/LogoutAsync.AutoRefreshSession = falsenow disables all self-refresh (was: no timer); the timer isBackgroundRefresh.LogoutAsyncthrows on service-side failure (after local teardown).ClaimsFactoryisFunc<OAuthSession, IEnumerable<Claim>>.Verification
3fb7938(#119 parts 2–4 and #123's wire-fidelity drift test merged). Full solution build with--no-incremental: 0 warnings, 0 errors.tests/ATProtoNet.Tests/Lexicon/Upstream/(37 pass). The drift test's call scanner now recognizesProcedureWithTokenAsync(it still named the removedProcedureWithRefreshTokenAsync), socreateSession,refreshSession,deleteSessionandcreateAccountare checked against upstream as POSTs; a misspelt NSID there fails it.PasswordSessionLifecycleTests(35 cases): login (JWTexp→ExpiresAt, no auth header on createSession), entryway → didDoc PDS and the three cases that must not be followed (foreign DID doc, HTTP dev PDS, HTTPS→HTTP downgrade), migration on refresh,CreateAccountAndLoginAsync, statelessServerClient(×3), resume on a fresh client with an expired token, resume updating account details,TryRestoreSessionAsync,ExpiredToken→ refresh → retry, 16 concurrent rejected callers → exactly onerefreshSession, 8 concurrentRefreshSessionAsync→ one refresh, proactive refresh (and not when far from expiry, and transient-failure fallback), refresh token rejected →Expired+ store cleared, transient failure keeps session, auto-refresh off, background timer via a manualTimeProvider, logout with refresh JWT / failure surfaced after teardown / already-invalid token, throwing event handler,Disposewith an in-flight refresh returns in < 2 s,DisposeAsynccancels and awaits, disposal leaves suppliedHttpClient/store usable.OAuthSessionLifecycleTests(20): DPoP-bound requests, refresh form + DPoP proof (htu, noath),invalid_grant→ session expired, nothing stored, 5xx error body never taken for tokens, 2xx without access token rejected,use_dpop_nonceanswered once and nonce remembered,invalid_tokenchallenge → refresh → retry, 12 concurrentinvalid_tokencallers → one token request, proactive OAuth refresh, missingOAuthClient, re-apply keeps previousOAuthClient, password login after OAuth refreshes viarefreshSession, malformed DPoP key leaves client untouched, RFC 7009 revocation (refresh token + hint + client_id + DPoP), revocation endpoint discovery, access-token revocation without refresh token, revocation failure surfaced after teardown, nodeleteSessionfor OAuth, disposal leavesOAuthClientusable,OAuthClient.RefreshAsyncimmutability.Logout_RevokesTheRefreshTokenandRefreshSession_RotatesTheTokensAndKnowsTheirExpiry; the suite compiles.Review follow-up
An adversarial review reproduced one blocking and three should-fix defects with 7 probes; each is now a regression test (mostly in
tests/ATProtoNet.Tests/Auth/SessionRaceTests.cs), and each fix was mutation-checked (reverting it makes its test fail).TryRecoverAsyncnow returns the credentials to resend with, and only credentials of the same account on the same service (XrpcCredentialscarriesAccountandService).XrpcClientpublishes the service URL and credentials as one snapshot (SetSession), a call reads it once after the proactive refresh, and every attempt (DPoP nonce retry, 429 wait) uses that snapshot; only a same-account recovery replaces its credentials. Session state is published before the transport snapshot, so a call is always compared against the right generation. Tests:ACallRejectedAfterAnAccountSwitch_IsNotResentWithTheOtherAccountsToken(P2),ACallWaitingOutA429_IsResentToItsOwnServiceWithItsOwnToken_AfterASwitch,AProactiveRefreshThatMovesThePds_SendsTheCallToTheNewPds.TimeoutExceptiononly then), and the refreshed session is persisted with a non-cancellable write.DisposeAsyncwaits for it;Disposelets it land in the background and releases the DPoP key afterwards. Tests:ARefreshWhoseCallerGivesUpAndWhoseClientIsDisposed_StillStoresTheRotatedToken(P1),DisposingDuringTheStoreWrite_LetsTheWriteFinish(P6), and the two rewritten disposal tests inPasswordSessionLifecycleTests.invalid_grantremoved the store entry unconditionally. Expiry re-reads the store and removes the entry only if it still holds the refused refresh token, so a losing per-request client no longer deletes the winner's fresh session (full coordination stays with #136). Test:ALosingConcurrentRefresh_LeavesTheWinnersStoredSessionAlone(P7).ABackgroundRefreshThatFailsTransiently_IsRetriedWithBackoff(P3).ResumeSessionAsyncdoc vs behaviour (P4). A session the service rejects during resume (XrpcAuthenticationException, after a refresh where one applied) is now removed from the client and the store and reportedExpired; other failures leave it installed. Doc updated. Tests:ResumeSessionAsync_ATokenTheServiceRejects_RemovesTheSession,ResumeSessionAsync_ServiceUnreachable_KeepsTheSession.ApplySessionAsync_CallerCancelsDuringTheStoreWrite_TheWriteAndTheEventStillHappen.ObjectDisposedException. A call whose session was replaced or signed out while in flight now fails withXrpcAuthenticationException(AuthenticationRequired) and sends nothing. Test:ACallOfASessionReplacedMeanwhile_FailsInsteadOfSigningWithTheReleasedKey.ANonSeekableUploadRejectedAsExpired_RefreshesButIsNotResent.OAuthClientnow throwsInvalidOperationExceptionafter the local teardown instead of only logging. Test:LogoutAsync_AnOAuthSessionWithoutItsOAuthClient_TearsDownThenThrows.ToString().XrpcCredentialsand the session state record override it (account, service, DPoP flag only); the unusedXrpcClient.Credentialsis removed. Test:Credentials_ToString_NeverShowsTheTokens._sessionalias inAtProtoClient.csis inlined as_sessions.Session.invalid_tokenwith aDPoP-Nonceno longer spends the nonce retry first; the session recovery resends with the new nonce (2 requests instead of 3). Test:AnInvalidTokenChallengeCarryingANewNonce_IsRecoveredWithoutASeparateNonceRetry.JsonProbes.cs): malformed stored sessions (unknown$kind, invalid DID, relative endpoint, non-base64 DPoP key, legacy or current) now always throwJsonException, which the file and EF stores treat as a corrupt entry; added toStoredJson_Garbage_ThrowsJsonException.Deliberately deferred / notes
LoginAsyncis left to #120. This PR only adopts thedidDocPDS thatcreateSession/refreshSessionreturn, in one seam:SessionManager.ResolveServiceEndpoint(didDoc, did, current). It moves only between HTTPS services, because a dev PDS publishes an address (http://localhost:3000,https://pds.test) that is usually not how the client reaches it (CI reaches it athttp://pds:3000).ServiceAuthGeneratorownsKeystays with #119 part 4 (file untouched).sub/scope validation).🤖 Generated with Claude Code
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 2% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-25T03:54:33.092Z) — nothing to re-trigger.
@claude /review — this is the core auth path. Please read these in full from the branch:
SessionManager(install/refresh single-flight and generation check), the refresh path inXrpcClient's pipeline (proactive and reactive, one retry),OAuthClient's refresh status check and revocation, andAtProtoClient's dispose path.🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
💬 Verdict: comment — notes only, nothing blocking.
This is a very large, well-documented refactor (unified session model, single-flight refresh, revised persistence). The portion of the diff actually shown is internally consistent with the PR description: the breaking-change/CHANGELOG entries line up with the code changes visible (session-store renames,
ServerClientbecoming stateless,PdsAdminClientnow setting tokens explicitly, Blazor claims/logout usingOAuthSession, EF Core/file stores reading legacy 0.6 payloads viaAtProtoSessionJson, DI registrations making the session store optional). I did not find a clear correctness bug in what is visible.Caveat: the diff is truncated before the core of the new lifecycle machinery (
SessionManager, theIXrpcSessionHandlerpipeline hookup insideXrpcClient, the single-flight/generation-check refresh logic, and the new test files) is shown. Those are exactly the pieces the PR's own risk analysis calls out (concurrency correctness, refresh-token single-use safety, disposal timing), so this review can't independently confirm those guarantees hold — it can only confirm that the parts shown (call sites, DI wiring, docs) are consistent with the described design. Given the PR's own verification section (mutation testing disabling the generation check, 16/12/8-concurrent-caller tests), this is probably fine, but a maintainer should make sureSessionManager.csand theXrpcClientpipeline changes get eyes too if they haven't already been reviewed in a separate pass.A couple of very minor things worth a look, not blockers:
AtProtoClient.csadds a private property_session(underscore-prefixed, like a field) that just forwards to_sessions.Session, duplicating the publicSessionproperty one screen above it. Harmless, but the naming makes it easy to mistake for a backing field during future edits — might be worth inlining_sessions.Sessionat the (unshown) call sites instead of keeping this alias.PdsAdminClient.csnow calls_adminXrpc.SetTokens(session.AccessJwt)with only the access token (no refresh token), consistent withServerClientno longer auto-installing sessions; since the admin session is short-lived and admin auth is otherwise HTTP Basic, this looks intentional rather than an oversight, but worth a sanity check that nothing downstream expects the admin XRPC client to be able to refresh.Overall: no findings confirmed severe enough to request changes based on what's visible; recommend the reviewer also look at
SessionManager.cs/XrpcClientpipeline changes directly if that hasn't happened, since that's where the hardest concurrency guarantees live.⏱ 1m 15s · 1 turns · tokens: 76.1k in / 7.1k out (+23.9k cache-read)
📜 Full session transcript
🤖 Resolving the merge conflicts — the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the fix runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 2% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-25T03:54:33.092Z) — nothing to re-trigger.
c8c5a544e703f98f0bedFollow-up in
03f98f0, rebased onto main after #159.Because the automated review only saw a truncated diff, I ran an independent adversarial review of the session core (
SessionManager, the refresh path inXrpcClient, theOAuthClientrefresh and revocation, and dispose). It reproduced 1 blocking and 3 should-fix defects with probe tests. All are fixed, and each now has a regression test. Reverting any of the fixes fails its test.invalid_grant, a client removed a session that another client had just refreshed. The entry is now removed only if it still holds the refused token.Plus the nits: the resume doc/behaviour, non-cancellable post-change store writes, tokens no longer shown by
ToString, the_sessionalias inlined, andinvalid_tokencombined with a DPoP nonce needing 2 requests instead of 3.LoginAsync(allowTakendown:)is new. See Review follow-up in the description.@claude /review
🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 2% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-25T03:54:33.092Z) — nothing to re-trigger.
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-25T08:55:03.502Z) — nothing to re-trigger.
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-25T13:55:34.014Z) — nothing to re-trigger.
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-25T18:56:04.484Z) — nothing to re-trigger.
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-25T23:56:34.971Z) — nothing to re-trigger.
@claude /review — re-requesting; the previous request coincided with a usage-limit window. See the follow-up comment above for what changed since the last review.
🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
Re-review after the adversarial-review follow-up commits. My previous pass flagged that the diff shown was truncated before
SessionManager.csand theXrpcClientpipeline hookup, which are exactly where the hardest concurrency guarantees live, so this time I fetched the fullissue-124-sessionbranch and read both files in full (not just the diff) rather than relying on the truncated PR diff text.SessionManager.cs: the single-flight refresh (RefreshOperationkeyed by the observedXrpcCredentialsreference, joined underlock (_gate)), the generation check (ReferenceEquals(state.Credentials, rejected)/SameSessionaccount+service comparison), the non-cancellable persist-after-transition pattern, theExpireLockedAsync/ForgetAsync"only remove from store if it still holds the refused token" logic, and theDispose/DisposeAsyncsplit (background continuation disposing the DPoP key only after an in-flight exchange finishes) all match what the PR description claims, and I didn't find a race or ordering bug in the state machine. The_state = next(compare-state) vs_xrpc.SetSession(...)(send-state) two-step publish inPublish()has a theoretically nonzero instruction-reordering window, but it's two adjacent synchronous statements with no await between them, not a real exploitable race.XrpcClient.cs:SendAsyncreads_targetinto a localtargetonce, afterBeforeSendAsynchas had a chance to proactively refresh, and every retry (DPoP nonce, 429, session recovery) reuses that same snapshot except for a session recovery, which is only allowed to replaceCredentialswith whatTryRecoverAsyncreturns (already constrained to same-account/same-service bySessionManager.SameSession). The disposed-DPoP-key race is handled by catchingObjectDisposedExceptionfromApplyAuthorizationand turning it intoXrpcAuthenticationException(AuthenticationRequired)rather than letting a rawObjectDisposedExceptionescape, matching the "nit" fix claimed in the PR description. The DPoP-nonce-vs-invalid-token disambiguation (HasInvalidTokenChallengegating the plain nonce-retry branch) also matches the "no separate nonce retry when the challenge already carries a new nonce" optimization described.I did not attempt to re-verify every one of the ~55 new/changed test names by running the suite, but the two core files that implement the concurrency contract are internally consistent, well-documented, and I found no correctness bug in them. Combined with the extensive breaking-change/CHANGELOG bookkeeping (which lines up with the code), this looks ready to merge.
⏱ 1m 24s · 15 turns · tokens: 127.2k in / 7.5k out (+1627.5k cache-read)
📜 Full session transcript