feat(auth)!: unified session model and a reliable session lifecycle #124

Closed
opened 2026-09-24 12:39:06 +00:00 by Grandiras · 0 comments
Owner

Part of the v0.7.0 update cycle (wave 5). Session handling has several correctness bugs, and two parallel persistence abstractions, one of which is never read.

Bugs

  • Logout revokes nothing. deleteSession requires the refresh JWT but is sent the access JWT, and the error is swallowed. OAuth logout never calls the revocation endpoint. Fix both: deleteSession(refreshJwt), and RFC 7009 revocation with DPoP for OAuth. Surface failures after local teardown.
  • A failed OAuth refresh is stored as a token. RefreshTokensAsync never checks the HTTP status, so an invalid_grant body becomes an empty access token that the client then uses.
  • ResumeSessionAsync on a fresh client throws "No session to refresh" instead of refreshing, which the docs promise it does.
  • A password login after an OAuth session keeps refreshing through OAuth; the OAuth state is never cleared. Session installation also runs outside the refresh lock.
  • Raw sub-clients mutate the client's auth state as a hidden side effect (Server.CreateSessionAsync/CreateAccountAsync/DeleteSessionAsync). Make sub-clients stateless; only AtProtoClient installs sessions. Add a CreateAccountAndLoginAsync convenience.
  • Nothing refreshes reactively on ExpiredToken or DPoP invalid_token.

Model

  • abstract record AtProtoSession(Did, Handle, Uri ServiceEndpoint) with PasswordSession and OAuthSession (immutable, ExpiresAt instead of ExpiresIn + obtained-at). AtProtoClient.Session becomes the single property. Add event SessionChanged (Created / Refreshed / Expired / Removed).
  • One persistence abstraction replaces ISessionStore (whose LoadAsync the SDK never calls) and IAtProtoTokenStore. Keep the in-memory, file and EF implementations working, and document the EF migration if the schema changes.
  • An internal auth DelegatingHandler: proactive refresh before ExpiresAt - skew (decode exp from legacy JWTs instead of assuming 115 min), and a single-flight reactive refresh plus one retry on ExpiredToken/invalid_token, with a refresh generation check so concurrent callers don't refresh twice. The background timer becomes optional.
  • LoginAsync(AtIdentifier, password): when no service URL was configured explicitly, resolve handle → DID → PDS through the identity resolver, and adopt the didDoc PDS that createSession returns.

Lifetime

  • DisposeAsync is primary. Synchronous Dispose must not block up to 35 s waiting for the timer.
  • Ownership rules for keys and sessions passed in (for example ServiceAuthGenerator(…, ownsKey: false)).
  • Publish auth state atomically (one immutable object).
  • Document the thread-safety contract.

CHANGELOG Breaking changes (session types, store interface, deleteSession signature, sub-client side effects) with migration notes; Fixed for every bug.

Part of the v0.7.0 update cycle (wave 5). Session handling has several correctness bugs, and two parallel persistence abstractions, one of which is never read. ## Bugs - [ ] **Logout revokes nothing.** `deleteSession` requires the **refresh** JWT but is sent the access JWT, and the error is swallowed. OAuth logout never calls the revocation endpoint. Fix both: `deleteSession(refreshJwt)`, and RFC 7009 revocation with DPoP for OAuth. Surface failures after local teardown. - [ ] **A failed OAuth refresh is stored as a token.** `RefreshTokensAsync` never checks the HTTP status, so an `invalid_grant` body becomes an empty access token that the client then uses. - [ ] `ResumeSessionAsync` on a fresh client throws "No session to refresh" instead of refreshing, which the docs promise it does. - [ ] A password login after an OAuth session keeps refreshing through OAuth; the OAuth state is never cleared. Session installation also runs outside the refresh lock. - [ ] Raw sub-clients mutate the client's auth state as a hidden side effect (`Server.CreateSessionAsync`/`CreateAccountAsync`/`DeleteSessionAsync`). Make sub-clients stateless; only `AtProtoClient` installs sessions. Add a `CreateAccountAndLoginAsync` convenience. - [ ] Nothing refreshes reactively on `ExpiredToken` or DPoP `invalid_token`. ## Model - [ ] `abstract record AtProtoSession(Did, Handle, Uri ServiceEndpoint)` with `PasswordSession` and `OAuthSession` (immutable, `ExpiresAt` instead of `ExpiresIn` + obtained-at). `AtProtoClient.Session` becomes the single property. Add `event SessionChanged` (Created / Refreshed / Expired / Removed). - [ ] One persistence abstraction replaces `ISessionStore` (whose `LoadAsync` the SDK never calls) and `IAtProtoTokenStore`. Keep the in-memory, file and EF implementations working, and document the EF migration if the schema changes. - [ ] An internal auth `DelegatingHandler`: proactive refresh before `ExpiresAt - skew` (decode `exp` from legacy JWTs instead of assuming 115 min), and a **single-flight** reactive refresh plus one retry on `ExpiredToken`/`invalid_token`, with a refresh generation check so concurrent callers don't refresh twice. The background timer becomes optional. - [ ] `LoginAsync(AtIdentifier, password)`: when no service URL was configured explicitly, resolve handle → DID → PDS through the identity resolver, and adopt the `didDoc` PDS that `createSession` returns. ## Lifetime - [ ] `DisposeAsync` is primary. Synchronous `Dispose` must not block up to 35 s waiting for the timer. - [ ] Ownership rules for keys and sessions passed in (for example `ServiceAuthGenerator(…, ownsKey: false)`). - [ ] Publish auth state atomically (one immutable object). - [ ] Document the thread-safety contract. CHANGELOG `Breaking changes` (session types, store interface, `deleteSession` signature, sub-client side effects) with migration notes; `Fixed` for every bug.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/ATProto.NET#124
No description provided.