Skip to content

feat(send): introspect access tokens per request and force logout on revoked sessions#974

Open
aaspinwall wants to merge 6 commits into
mainfrom
feat/introspect-tokens-per-request-960
Open

feat(send): introspect access tokens per request and force logout on revoked sessions#974
aaspinwall wants to merge 6 commits into
mainfrom
feat/introspect-tokens-per-request-960

Conversation

@aaspinwall

Copy link
Copy Markdown
Collaborator

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-logout response header, and the frontend clears its local auth and returns to login.

Key behaviors:

  • Forced logout fires only for a genuine revocation (an access token still within its lifetime that Keycloak reports inactive). Routine access-token expiry is left to the normal refresh flow, so this does not log people out mid-session.
  • Introspection results are cached (~60s) so a burst of requests (e.g. an upload) isn't one Keycloak call per request, and it fails open if Keycloak is unreachable — an outage can't sign everyone out.
  • The forced-logout redirect is dispatched as a window event so the shared auth store (also bundled into the Vue-less background script) never imports the router.

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

  • Enforcement requires OIDC_TOKEN_INTROSPECTION_URL, OIDC_CLIENT_ID, and OIDC_CLIENT_SECRET to be set per environment (stage/prod). If they're absent, introspection fails open and there is no enforcement.
  • The revoke → forced-logout path is covered by backend + frontend unit tests and a real-browser web-app check (valid sessions see zero spurious logouts; an injected x-logout triggers 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.

aaspinwall and others added 5 commits July 8, 2026 13:04
…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>
@aaspinwall aaspinwall requested a review from radishmouse July 8, 2026 22:42

@MelissaAutumn MelissaAutumn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just want to make sure we're not going to cause even more problems without a silent refresh.

Comment thread packages/send/backend/src/trpc/middlewares.ts
… 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>
Comment thread packages/send/frontend/src/lib/api.ts Dismissed
@aaspinwall aaspinwall requested a review from MelissaAutumn July 9, 2026 22:03
@aaspinwall

Copy link
Copy Markdown
Collaborator Author

Just want to make sure we're not going to cause even more problems without a silent refresh.

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:

  • recoverOrForceLogout() in auth-store.ts runs a deduped signinSilent(). If the session rolls forward, we're good; we only force logout when the refresh token itself is gone. Transient/network errors keep the session (fail open), so a blip can't sign anyone out.
  • api.ts and trpc.ts call it on x-logout and retry the request once with the freshly-rotated token instead of bouncing to login. In api.ts it's branched ahead of the plain-401 path so a revoked session gets exactly one refresh attempt.

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.

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.

Introspect access tokens per request

3 participants