feat(send): introspect access tokens per request and force logout on revoked sessions#974
Open
aaspinwall wants to merge 6 commits into
Open
feat(send): introspect access tokens per request and force logout on revoked sessions#974aaspinwall wants to merge 6 commits into
aaspinwall wants to merge 6 commits into
Conversation
…revoked sessions Verify the OIDC access token against Keycloak's introspection endpoint on each authenticated request, so a session that ended server-side (logout elsewhere, password change, admin force-logout) loses access within ~1 minute instead of lingering until the access-token TTL expires. Backend: - requireJWT, requireAuth, and the tRPC auth middleware now reject an inactive token (introspection active:false) instead of falling back to a still-valid JWT cookie, and set an `x-logout` response header. Introspection is fail-open when Keycloak is unreachable so an outage can't sign everyone out. - Introspection cache shortened to 60s; `x-logout` exposed via CORS. Frontend: - api.call and the tRPC transport clear local auth (handleForcedLogout) and return to login when a response carries `x-logout`. Closes #960 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion on tRPC Address two gaps found while verifying #960 end-to-end: - Cooperate with token refresh: force logout ONLY when an access token is still within its lifetime but Keycloak reports it inactive (a real revocation). Routine expiry now falls through to the normal refresh flow instead of logging the user out (isAccessTokenRevoked decodes the token exp and skips the x-logout path for expired tokens). Fail open on introspection errors. - The tRPC transport was cookie-only, so revocation was never enforced there. It now attaches the OIDC access token so the backend introspects it per request, matching the REST path. Verified in a real browser: a valid session drives REST + tRPC with zero spurious logouts, and an injected x-logout triggers the client's forced-logout redirect. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
handleForcedLogout redirected via window.location.assign('/send'), which works
in the web app but would not resolve inside the add-on (the same SPA runs at
moz-extension:// with history routing, so a hard path navigation has no file to
serve). Navigate with the client-side router (router.replace('/login')) instead,
which works in both contexts; keep the hard-navigation fallback for
non-router environments.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…kground build)
The previous fix imported the Vue router into the auth store to navigate on
forced logout. But the auth store is also bundled into background.ts, whose Vite
config has no Vue plugin — so pulling in the router (and every .vue page it
imports) broke the background build with 23 .vue parse errors (build.sh has no
set -e, so it printed success anyway).
Dispatch a `tbpro:force-logout` window event from the store instead; the router
module (app bundle only) listens and does the client-side router.replace('/login').
Keeps the redirect add-on-safe while keeping the router out of the background bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fix a latent bug: forcedLogoutInProgress was never reset, so after the first forced logout (a client-side redirect, no reload) handleForcedLogout became a permanent no-op. Reset it in `finally`. - Rename isSessionRevoked -> rejectIfSessionRevoked and let it own the 401 + x-logout response, collapsing two identical guard blocks; the old name implied a pure predicate while it set a header. - Move X_LOGOUT_HEADER to config.ts so the tRPC middleware no longer imports the heavy models/prisma graph just for a string; reference it from origins.ts too. - Document that the refresh-vs-revoke distinction depends on JWT access tokens (decodeTokenExp) so an opaque-token switch is a known trap. - Add tests: requireAuth + tRPC isOIDCAuthed revocation (401/FORBIDDEN + x-logout), handleForcedLogout (clears + dispatches, guard resets), and fetchWithLogoutCheck (attaches bearer, honors x-logout). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MelissaAutumn
requested changes
Jul 9, 2026
MelissaAutumn
left a comment
Member
There was a problem hiding this comment.
Just want to make sure we're not going to cause even more problems without a silent refresh.
… logout Address review on #960: on an x-logout (a Keycloak-revoked access token), the frontend now attempts a silent refresh before tearing the session down, and only forces logout when the refresh token is also gone. A transient refresh error keeps the session (fail open). - auth-store: new recoverOrForceLogout() runs the deduped silent refresh; refreshAccessToken now records whether a failure was genuine so the decision keys off the failure kind, not isLoggedIn (which can be false during an extension cold-start and would otherwise turn a network blip into a logout). - api.ts / trpc.ts: on x-logout, recover and retry the request once with the fresh token instead of unconditionally forcing logout. In api.ts x-logout is branched ahead of the plain-401 path so there is exactly one refresh. The OIDC refresh token lives only client-side (oidc-client-ts), so this recovery is necessarily on the frontend; the backend only signals revocation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collaborator
Author
I added the silent refresh in 6a8f5982 (after your review where you requested changes 🙂). Now, when the backend flags a revoked access token (x-logout), the frontend doesn't tear the session down right away. It first tries a silent refresh with the refresh token:
The refresh token lives only client-side (oidc-client-ts), so this recovery has to be on the frontend — the backend just signals revocation. Covered by new tests in auth-store.test.ts, api.test.ts, and trpc.test.ts. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed?
The backend now introspects the OIDC access token against Keycloak on each authenticated request (both the REST API and tRPC). When a session has been ended server-side — logged out elsewhere, password change, or an admin force-logout — the backend returns an
x-logoutresponse header, and the frontend clears its local auth and returns to login.Key behaviors:
AI disclosure: implemented by an AI agent (Claude) working from my direction; I reviewed the result, and it went through an automated senior-code-review pass whose findings were folded in.
Why?
Without this, a revoked session keeps working until the access token's TTL expires — so a logout, password change, or admin revoke doesn't actually cut off access for up to that window. Checking token liveness per request closes that gap to ~1 minute.
Limitations and Notes
OIDC_TOKEN_INTROSPECTION_URL,OIDC_CLIENT_ID, andOIDC_CLIENT_SECRETto be set per environment (stage/prod). If they're absent, introspection fails open and there is no enforcement.x-logouttriggers the redirect). A manual pass in the Thunderbird add-on — sign in, revoke the session elsewhere, confirm it returns cleanly to login — is recommended before/at merge, since that exact GUI path can't be driven headlessly.Applicable Issues
Closes #960
Screenshots
N/A — backend/auth behavior with no UI visuals.