Skip to content

security: make access tokens revocable (token generation counter) - #12

Open
IbraTech04 wants to merge 1 commit into
minbar-featuresfrom
sec/unit7-access-token-revocation
Open

IbraTech04 wants to merge 1 commit into
minbar-featuresfrom
sec/unit7-access-token-revocation

Conversation

@IbraTech04

Copy link
Copy Markdown
Collaborator

The problem

AuthController password-change revokes all refresh tokens via revokeAllUserTokens, but the already-issued JWT access token stays valid until natural expiry (lensbridge.app.jwtExpirationMs=900000, 15 min). There is no jti denylist and no version check, and a JWT is self-contained — so a stolen access token remains usable for up to 15 minutes after the victim changed their password precisely to stop it. Same gap on logout-all-devices.

The fix

  • Every token carries a tv claim holding the account's token generation at mint time.
  • AuthTokenFilter compares it against users.token_version and, on mismatch, leaves the security context unauthenticated so the entry point answers 401. The filter already loads the user on every request, so this costs no extra query.
  • Authorities are still resolved from the DB rather than read off the token — existing behaviour, deliberately preserved.
  • StompJwtChannelInterceptor makes the same check at CONNECT. A WebSocket is authenticated once and then lives a long time, so honouring an invalidated token there would outlive the JWT's own expiry rather than merely precede it.
  • AuthenticatedUser.tokenVersionOf returns -1 for any principal that cannot report a generation, so an unexpected UserDetails fails closed instead of silently matching version 0.

Verification

AuthTokenFilterRevocationTest   tests=3   failures=0  errors=0
MigrationParityTest             tests=3   failures=0  errors=0
---
Tests run: 578, Failures: 0, Errors: 0, Skipped: 0     BUILD SUCCESS

AuthTokenFilterRevocationTest covers the three cases that matter: a current-generation token authenticates; a token from before a password change is rejected despite a valid signature and an unexpired clock; and a principal that cannot report a generation is rejected.

StompJwtChannelInterceptorTest's fixture built a plain Spring User, which now fails closed at -1, so it was updated to construct an AuthenticatedUser — the test was stale against a deliberate design choice, not a bug.

⚠️ Deploy consequence

Existing tokens carry no tv claim and are treated as stale, so live sessions re-authenticate once at deploy.

Scope — one half of this unit is NOT done

The audit paired this with a second, unrelated defect: GlobalExceptionHandler returns "Account is disabled. Please verify your email." for DisabledException but "Invalid username or password" for BadCredentials/UsernameNotFound, which lets an attacker enumerate which *.utoronto.ca addresses are registered-but-unverified. That is not addressed here — GlobalExceptionHandler is untouched. It needs a decision about whether the frontend relies on that message to show a "resend verification" prompt, so it is better as its own change.

Merge notes

  • Migration is V9, not V8 — V8 is claimed by the refresh-token hashing PR on its own branch. No ordering dependency; Flyway tolerates the gap if this lands first.
  • Touches AuthController.java, which that same PR also edits. Expect a conflict depending on merge order.

🤖 Generated with Claude Code

https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm

Password change revoked every refresh token but left the already-issued
JWT working until its 15-minute expiry, because a JWT is self-contained
and there was no jti denylist or version check. A stolen access token
stayed usable for up to 15 minutes after the victim changed their
password specifically to stop it. Same gap on logout-all-devices.

Every token now carries a "tv" claim holding the account's token
generation at mint time. AuthTokenFilter compares it against
users.token_version on each request and leaves the context
unauthenticated when they differ, so the entry point answers 401.
The filter already loads the user on every request, so this adds no
query. Authorities are still resolved from the database rather than
read off the token, which is the existing behaviour and is preserved.

StompJwtChannelInterceptor makes the same check at CONNECT: a WebSocket
is authenticated once and lives long, so honouring an invalidated token
there would outlive the JWT's own expiry rather than merely precede it.

AuthenticatedUser.tokenVersionOf returns -1 for any principal that
cannot report a generation, so an unexpected UserDetails fails closed
rather than silently matching version 0.

V9 rather than V8: V8 is taken by the refresh-token hashing migration
on its own branch. No ordering dependency; Flyway tolerates the gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@IbraTech04

Copy link
Copy Markdown
Collaborator Author

Before reviewing:

This commit fundamentally makes authentication stateful - While not inherently a bad thing, at scale this may cause more unnecessary DB lookups. 🤷

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