refactor(server)!: OAuth hosting in ATProtoNet.Server, refresh coordination, working Blazor widgets #173
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!173
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-136-auth-hosting"
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?
Part of #136 (first half: auth, OAuth hosting and Blazor).
Covers the issue's Auth and Blazor sections, the two #167 review follow-ups, the confidential-client hosting deferred from #132, and the claim-type constants deferred from #125. The Packages and DI sections follow in the second PR; registration code keeps its current style so that PR can reshape it.
Auth
ATProtoNet.Server(ATProtoNet.Server.Authentication):AtProtoOAuthService,AtProtoOAuthServerOptions,AddAtProtoAuthentication(),MapAtProtoOAuth(), the login binding. Its tests moved with it (tests/.../Server/Authentication/); the #167 security properties (login-CSRF binding cookie, local-only return URLs, per-requester pending-login bound, issuer verification) are unchanged and still covered.AtProtoAuthenticationHandlerdeleted (withAtProtoAuthenticationOptions,AddAuthentication().AddAtProto()); service auth replaces it.IOAuthClientProviderdeleted.AtProtoOAuthService.Clientis built from the options alone (noHttpContext) and registered as theOAuthClientsingleton;AtProtoClientFactorytakes an optionalOAuthClient. The loopback client's callback comes fromBaseUrlor the server's plain-HTTP loopback address (IServer), so it is identical after a restart and stored sessions refresh.ISessionRefreshCoordinator+InProcessSessionRefreshCoordinatorandAtProtoClientOptions.RefreshCoordinator;SessionManagerrefreshes under the account's lock and re-reads the store after taking it: another client's refresh is adopted (new DPoP key loaded if it is a new sign-in), a session the store no longer holds ends (XrpcAuthenticationException,InvalidToken) without spending its token, otherwise it exchanges and persists before releasing.AddAtProtoServer()registers it; the factory's clients, the login's store write and logout all use it.DPoPProofGeneratorviews, so disposing a client never breaks another; LRU-bounded at 1,024 accounts) and one logger.FileAtProtoSessionStore: lock-free reads (FileShare.ReadWrite | Delete, atomic rename on write), per-account write locks (KeyedLock<T>, entries live only while held); a corrupt file is deleted only under the write lock after a re-read.AtProtoClaimTypes(Did,Handle,HandleVerified,PdsUrl,AuthMethod,LexiconMethod,Audience) absorbs the threeAtProtoServiceAuthDefaults.*ClaimTypeconstants.OAuthException.Error, the AS'serrorif it is a plain token (itserror_descriptionis dropped), elselogin_failed(logged).LoginFormmaps codes to messages and never displays the code.CompleteCallbackAsyncreturnsAtProtoOAuthCallbackResult(Did,RedirectUrl,IsRelay);IsIssuedRelayUrlis gone.ServeClientMetadata:MapAtProtoOAuth()servesClientMetadataat itsclient_idpath and, with ajwks_uri, the public halves ofClientKeysthere (fails at startup if misconfigured).AtProtoOAuthServerOptions.ClientKeys→OAuthOptions.ClientKeys(private_key_jwt).ResolveFromServerUrlAsyncpasses the resolved protected-resource metadata through (regression test with a clock that defeats the cache: 2 fetches, was 3); an unredeemed loopback relay entry is revoked when it expires (per-entry timer) or is evicted (new cap of 256).Blazor
FeedView,PostCard,ProfileCard,ComposePostuse the scopedAtProtoUserClientAccessor(AddAtProtoBlazor()), which creates the client viaIAtProtoClientFactoryfrom the auth state, shares it per scope, replaces it when the user changes. They previously injected an unregisteredAtProtoClient.FeedSourceis a record type (Timeline,Author,Feed,List);FeedViewreloads only when source/page size change and renders duplicate posts.PostCardlikes/reposts/undoes itself without callbacks;ProfileCardloads byActor;ComposePostcounts graphemes and blocks >300. Error text is generic, exceptions are logged._content/ATProtoNet.Blazor/atproto-components.css; every class documented indocs/blazor.md.<SupportedPlatform Include="browser" />removed; core's staleInternalsVisibleTo ATProtoNet.Blazorremoved.OAuthClient.HttpClient/ClientId,AtProtoClient.HttpClient, relay internals); the one left reads the BCL'sHttpMessageInvoker._handler.samples/ServerIntegrationSample:AddAtProtoBlazor(), profile page onProfileCard, timeline page onComposePost+FeedView, claim constants, the stylesheet.Breaking changes (migration)
ATProtoNet.Server(Blazor still brings it) and replaceusing ATProtoNet.Blazor;/using ATProtoNet.Blazor.Authentication;withusing ATProtoNet.Server.Authentication;. Registration calls unchanged.AddAuthentication().AddAtProtoServiceAuth(o => o.Audiences.Add(...))+[RequireServiceAuth].IOAuthClientProviderremoved: register yourOAuthClientas a singleton;TryGetClient()→Client.AtProtoClientFactoryctor:(store, httpClientFactory, loggerFactory, OAuthClient? = null, ISessionRefreshCoordinator? = null).CompleteCallbackAsyncreturnsAtProtoOAuthCallbackResult→ redirect toresult.RedirectUrl;AtProtoOAuthServicector gains optionalIServer,ISessionRefreshCoordinator.AtProtoServiceAuthDefaults.DidClaimType/LexiconMethodClaimType/AudienceClaimType→AtProtoClaimTypes.Did/LexiconMethod/Audience.BaseUrl/ClientMetadataneeds a plain-HTTP loopback binding (clearInvalidOperationExceptionotherwise).AddAtProtoBlazor();FeedSource="author:x"→FeedSource="@FeedSource.Author(AtIdentifier.Parse("x"))";ProfileCard.Profileno longer[EditorRequired].erroras a code).Verification
--no-incremental): 0 warnings, 0 errors.ConcurrentRequests_WithoutACoordinator_SpendTheRefreshTokenTwice(control: two token requests, oneinvalid_grant) vs…WithACoordinator_SpendTheRefreshTokenOnce(the first exchange is held until the second client is waiting on the lock; one exchange, both calls useat-2); plus 16 concurrent clients, and 8 concurrent requests through the factory. Stable over repeated runs.TestServer(error codes, no message leak, metadata/JWKS served with public keys only, DI wiring), relay revoke on expiry/eviction with a manual clock, loopback address mapping, logout waiting for an in-progress refresh, file-store readers never seeing a missing/partial file under concurrent rewrites./,/login, CSS asset 200; bad handle →/login?error=invalid_handle; AS error →/login?error=access_denied; a real/atproto/login?handle=bsky.appPARs at bsky.social withclient_id=http://localhost?redirect_uri=http://127.0.0.1:5077/atproto/callback….CreateClientForUserAsync(OAuth session, in-memory store): ~27 µs → ~4 µs per call, 8,656 → 4,536 B allocated.FileAtProtoSessionStore, 64 concurrent callers: reads ~50k → ~290k ops/s (≈5.5×); 10 % writes ~36k → ~280k ops/s; single caller unchanged (~45–55k).Review fixes
Seven commits on top of
c1a6f4a, each with a regression test that fails without it (checked by reverting the fix) and its CHANGELOG change:231f3b2). Four client paths change the store: sign-out,ForgetAsyncon expiry,InstallAsync(persist)andUpdateAccountAsync. All four now take the account's lease after_transition, as refresh does. If a sign-out lands during another client's refresh, it waits for the refresh to finish, removes the stored result and revokes the token that refresh produced (rt-2), not its own spent copy.UpdateAccountAsyncwrites only while the store still holds its tokens. Tests: sign-out during a held refresh leaves the store empty and revokesrt-2; a sign-in during a held refresh keeps the new session.http/https(4966526). Any other scheme (javascript:,data:, …) renders as adivwith no link. The widgets have no other author-controlledhref.86bfd91).OAuthLoginState.TryParsedrops a return URL that is not local. It refuses an origin that is not a barehttp(s)origin, which ends inlogin_not_bound. New test: a login started on a public host with a loopback callback is not relayed. The relay test now also checks that the missing-binding case revokes the session.3734205). The factory, the accessor and/atproto/logoutread the DID only from an identity the login issued (authentication typeATProto) or from one carryingauth_method=oauth. Service-auth identities are ignored, including on principals that also carry the cookie identity. CustomClaimsFactoryprincipals keep working because they still get theATProtoidentity type. Listed as a breaking change: principals built by hand needauth_method=oauth.79a2770). This happens when a factory client's session is removed or expires, on the hosted logout, and when a request finds the store empty. Only a key matching the ended session is dropped, and it is disposed.24cdeaf). The served metadata and JWKS carryCache-Control: public, max-age=300. The README's hosting bullet no longer mentions the removed JWT handler.e400b87).FeedViewandProfileCardmark a load done only after it succeeds. They take the cascadingAuthenticationState, so a sign-in, a sign-out or a re-render after a failure triggers a reload.AtProtoUserClientAccessorgetsIAtProtoSessionStorein its constructor and reads the store on every call, so a session signed out elsewhere stops being handed out.After review: 4863 passed, 0 failed, 5 skipped; 0 warnings, 0 errors.
Deferred / flagged
OAuthClientfirst); it lasts at most two minutes.IAtProtoClientFactorybefore the server has started, with the dev loopback client and noBaseUrl, throws (the server's addresses are not known yet); production (ClientMetadata) is unaffected. The second half's DI/options work may want to revisit.ISessionRefreshCoordinator(documented, not shipped).CLAUDE.mdstill calls the Server package's auth a "JWT auth handler" and puts the OAuth endpoints in Blazor; left for the owner to edit.src/ATProtoNet.Server/Spaces/ISpaceCallerResolver.cs(#133) — one doccrefpointed at the deleted handler.🤖 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 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-26T21:57:27.503Z) — nothing to re-trigger.
@claude /review — security-relevant: the OAuth cookie login moves from Blazor into ATProtoNet.Server (the #167 CSRF binding, local-only return URLs and bounded pending logins must survive unchanged), the bearer-JWT handler is deleted, and a per-DID refresh coordinator is added. Please read in full from the branch: the moved AtProtoOAuthService and MapAtProtoOAuth in ATProtoNet.Server, ISessionRefreshCoordinator and its use in SessionManager, the client factory's per-DID cache, FileAtProtoTokenStore's locking, the client-metadata and JWKS endpoints, and the Blazor AtProtoUserClientAccessor. Focus on regressions in the moved security checks, refresh races, secrets in redirects, and the JWKS serving only public keys.
🤖 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.
@claude /review — retrying, since the previous run didn't post a review (the subscription hit its usage limit). Same focus as above: read the moved OAuth hosting in ATProtoNet.Server in full, plus the refresh coordinator, the client factory cache, FileAtProtoTokenStore locking, the client-metadata and JWKS endpoints, and AtProtoUserClientAccessor. Look for regressions in the moved security checks (CSRF binding, local-only return URLs, bounded pending logins), refresh races, secrets in redirects, and whether the JWKS serves public keys only.
🤖 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.
The visible portion of this diff (Blazor package rewrite, docs, samples, CHANGELOG) is well executed and matches the PR description closely. The new
AtProtoUserClientAccessor,FeedView/PostCard/ProfileCard/ComposePostrewrites are coherent: the gate-protected accessor correctly avoids leaking a client between different signed-in users, the feed source dispatch via a sealed-recordFeedSourceis clean, and the like/repost optimistic-state handling inPostCardlooks correct (state keyed offReferenceEquals(Post, _shown)so it survives re-renders of the same post but resets for a new one). Removing the explicitStateHasChanged()calls is safe since Blazor's event-handler dispatch already re-renders after the returned task completes. The textarea self-close fix and grapheme-based composer limit are good catches.Note up front: the diff supplied to me was truncated partway through the deleted
AtProtoOAuthService.cs/ newAtProtoAuthenticationExtensionsmapping code, so I could not see the actual newATProtoNet.Server.Authenticationimplementation (the OAuth service,ISessionRefreshCoordinator/InProcessSessionRefreshCoordinator, theFileAtProtoSessionStorelocking rewrite, DPoP key LRU cache, relay revocation timer,ServeClientMetadata, orFeedSource.csitself). Those are the bulk of the PR's substantive new logic per the description, so this review only speaks to what's visible: the Blazor components, docs, samples and changelog. A follow-up pass over the Server package files (and their tests) would be worth doing before merge if it hasn't already had one.Two minor, non-blocking observations below on the visible code.
⏱ 1m 54s · 2 turns · tokens: 87.5k in / 9.8k out (+116.3k cache-read)
📜 Full session transcript
@ -0,0 +58,4 @@await _gate.WaitAsync(cancellationToken);try{ObjectDisposedException.ThrowIf(_disposed, this);The cached client is reused whenever
current.IsAuthenticatedis true and the DID still matches, with no check against whether the store still holds a session for that account. The XML doc on this method says a caller getsnullagain "when... the session ends", but nothing here re-queries the store — it relies entirely onAtProtoClient.IsAuthenticatedflipping to false after a failed refresh/revoked session. Worth double-checking thatIsAuthenticatedis actually updated in that case (e.g. after the coordinator throwsXrpcAuthenticationException/InvalidTokenon a read), otherwise a long-lived circuit could keep returning a client for a session that was removed from the store elsewhere until the next explicit call that happens to trigger a refresh.@ -71,0 +105,4 @@if (Profile is not null || Actor is null){_loaded = null;_loadedActor = null;_loadedActoris set toactorbefore the fetch and never cleared on failure, so if the fetch fails only becauseUserClient.GetClientAsync()returned null (not signed in yet), the component will not retry on a later render with the sameActoreven after the user signs in — theActor == _loadedActorguard on the nextOnParametersSetAsyncshort-circuits before ever callingGetClientAsync()again. Same pattern applies toFeedView's_loadedSource/_loadedPageSizeguard. In practice the OAuth flow here is a full-page redirect that remounts components, so this is unlikely to bite, but if these widgets are ever reused in a scenario where sign-in happens without navigating away (e.g. a modal login within the same circuit), the card/feed would stay stuck on the sign-in prompt untilActor/FeedSourcechanges.@claude /review — re-review please. Seven new commits (231f3b2..e400b87) on top of
c1a6f4afix an independent adversarial review plus your two notes; see "Review fixes" in the description. Please read those commits in full (git diff c1a6f4a..e400b87) and the Server authentication files you couldn't see last time:SessionManagerstore changes now take the refresh lease (logout, expire, install, account update), with a 30 s wait fallback; sign-out also revokes a token refreshed concurrently.ATProtoauth type orauth_method=oauth) reaches a stored session through the factory, the accessor and logout.IsLocalUrlis re-checked on the stored login state at the callback.🤖 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 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-27T01:54:09.868Z) — nothing to re-trigger.
✅ Verdict: approve — no blocking issues found.
Re-review after the seven review-fix commits landed on top of my prior pass.
Both issues I raised previously are resolved, each with a regression test:
AtProtoUserClientAccessor.cs:61): commite400b87now hasGetClientAsyncre-checkIAtProtoSessionStoreon every call instead of trustingclient.IsAuthenticated, dropping the cached client when the store no longer holds the session.AtProtoUserClientAccessorTests.cscovers a session removed elsewhere while the client is still cached.ProfileCard.razor:108): the same commit changes both components to mark a load done only once it succeeds, and to key off the cascadingAuthenticationStateas well as their own parameters, so a sign-in, sign-out, or a parent re-render after a failure retries instead of latching on the first (failed or signed-out) attempt. New tests inFeedViewTests.cs/ProfileCardTests.cspin this.I also read through the previously-truncated
ATProtoNet.Server.Authenticationand core-auth substance that wasn't visible in my last pass (I was only able to review Blazor/docs/samples/CHANGELOG then):AtProtoOAuthService(client construction, callback/relay/logout flows, relay timer-based revocation and eviction),SessionManager's refresh-coordinator integration (single-flight refresh, lease acquisition around install/update/expire/logout, adopting another client's refreshed session, ending a session the store no longer holds),InProcessSessionRefreshCoordinator, andAtProtoClientFactory's DPoP key LRU cache. All of this is careful, consistent with the PR description, and the tricky concurrency invariants (single-use refresh token, no reviving a signed-out session, cached key torn down only when it matches the ended session) are reasoned through correctly and covered by the described regression tests (e.g. the two-client refresh race tests, sign-out-during-refresh tests).The other five review-fix commits (
4966526external-embed scheme allowlist,86bfd91re-validating the stored login state's origin/return-URL at the callback,3734205restricting factory/accessor/logout to the OAuth login's own identity,79a2770dropping the cached DPoP key on session end,24cdeafcache headers) all read correctly and match their CHANGELOG entries.No new blocking issues found. Approving.
⏱ 1m 32s · 29 turns · tokens: 129.7k in / 5.9k out (+3191.3k cache-read)
📜 Full session transcript
e400b870ccb4ae539a7aApproval carry-over check: claude-reviewer approved
e400b87. After #172 merged I rebased onto main, and CHANGELOG.md was the only conflict.verify-rebase: the patch against the merge base, excluding CHANGELOG.md, is byte-identical betweene400b87andb4ae539(8,344 changed lines). CI is green onb4ae539. Locally, the build has 0 warnings and 4,991 tests pass. Merging on the carried-over approval.