Skip to content

feat: definitive session teardown (forget) with a supersession fence#23

Draft
jeswr wants to merge 1 commit into
feat/renew-on-rejected-tokenfrom
feat/definitive-session-teardown
Draft

feat: definitive session teardown (forget) with a supersession fence#23
jeswr wants to merge 1 commit into
feat/renew-on-rejected-tokenfrom
feat/definitive-session-teardown

Conversation

@jeswr

@jeswr jeswr commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Stacked PR. Targets feat/renew-on-rejected-token (#14), which stacks on the refresh-token (#12) and session-cache (#11) PRs — it needs the session cache, the durable refresh token, and the transient invalidate() to contrast against. The diff shown here is only this change; rebase onto main once the stack lands.

The gap

The stack gives the session cache a transient stale path: invalidate() (#14) marks the access token stale but keeps the durable refresh token, so the next upgrade silently re-establishes the same session via the refresh grant (#12). That is exactly right for a rejected-token retry.

What is missing is the definitive counterpart — a log out / switch account primitive. There is currently no way to drop a session including its refresh token, so a consumer cannot de-authenticate: even after clearing UI state, the next upgrade refreshes the user straight back in.

The change

forget(request) on DPoPTokenProvider (plus an optional forget? on the TokenProvider interface, mirroring #14's invalidate?): it evicts the whole cached session for the request's issuer — refresh token included — so the next upgrade runs a fresh authorization-code flow. The transient/definitive split, side by side:

invalidate(request) (#14) forget(request) (this PR)
Access token marked stale dropped
Refresh token kept dropped
Next upgrade silent refresh grant fresh interactive authorization
Use rejected-token (401) retry logout / account switch

Why the fence

A bare delete is not enough: an upgrade that is already in flight (parked in discovery, the popup, or the token grant) read the session before forget and would still arm — completing the request with the just-forgotten credentials.

So each upgrade captures a per-issuer monotonic generation at entry and re-checks it right before it arms (attaches credentials). If forget bumped that issuer's generation in between, the upgrade fails closed with SessionForgottenError instead of attaching the discarded token. The counter is per-issuer, so forgetting one issuer never disturbs an in-flight upgrade for another; and because #begin installs the cache entry once up front (never re-installs after its awaits), a concurrent renewal cannot resurrect a forgotten session.

This is the "re-check the login generation at every await before arming" discipline the @jeswr app suite arrived at after a superseded-login / logout race let a stale login arm after the user had moved on — adapted to this repo's per-issuer session model.

Verification

  • npx tsc — clean (the CI check).
  • test/DPoPTokenProvider.forget.test.ts (5 cases, npm test): forget forces a fresh authorization on the next upgrade; the definitive-vs-transient contrast (invalidaterefresh_token grant with no popup, forgetauthorization_code with a fresh popup); a session forgotten mid-upgrade fails closed with SessionForgottenError and is not resurrected (the next upgrade re-authorizes); per-issuer isolation (forgetting issuer A leaves an in-flight upgrade for issuer B untouched); forget with no session is a safe no-op. Full suite (21 tests) green.

🤖 PSS agent — @jeswr's agent for the Solid app suite; proposing hardening the suite built in @jeswr/solid-auth-core. Draft for review — the API (a provider method vs a manager-level logout() that fans out to providers' forget?) is open to your preference.

The session cache has a *transient* stale path — invalidate() marks the access
token stale but KEEPS the durable refresh token, so the next upgrade silently
re-establishes the same session (correct for a rejected-token retry). What is
missing is the *definitive* counterpart: there is no way to log a user out /
switch accounts, i.e. drop the session including its refresh token so it does
NOT come back.

Add `forget(request)` on DPoPTokenProvider (and an optional `forget?` on the
TokenProvider interface, mirroring `invalidate?`): it evicts the whole cached
session for the request's issuer — refresh token included — so the next upgrade
runs a fresh authorization-code flow.

Doing that safely needs a supersession fence, or an in-flight upgrade could
still complete carrying the just-forgotten credentials. A per-issuer monotonic
generation is captured at the start of each upgrade and re-checked before the
upgrade *arms* (attaches credentials); if forget() bumped it in between, the
upgrade fails closed with SessionForgottenError instead of attaching the
discarded session's token. The fence is per-issuer, so forgetting one issuer
never disturbs an in-flight upgrade for another. Combined with the cache's
set-once install, a concurrent renewal cannot resurrect a forgotten session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant