Skip to content

feat(auth): revoke refresh tokens with a per-user token version (#104) - #107

Merged
kipavy merged 1 commit into
mainfrom
feat/refresh-token-revocation-104
Oct 11, 2026
Merged

kipavy merged 1 commit into
mainfrom
feat/refresh-token-revocation-104

Conversation

@kipavy

@kipavy kipavy commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Closes #104.

What changes

  • Migration 058: users.token_version INTEGER NOT NULL DEFAULT 0.
  • Refresh tokens carry ver. refresh answers 401 when it differs from the column. The column is read in the query fetch_tier already ran, so there is no extra round trip.
  • update_password bumps the version in the same UPDATE that stores the new hash.
  • New POST /v1/auth/logout-all (authenticated): bumps the version and returns {jwt_token, refresh_token} for the caller.
  • Both send token_invalidated, so other devices try to refresh immediately and get the 401.
  • Access tokens stay stateless: a revoked session keeps at most the rest of its hour.

Compatibility

  • Tokens issued before this have no ver and decode as 0, which matches the column default. Nobody is signed out by the deploy.
  • Released clients do not handle a 401 on refresh: the sync loop retries every five seconds. On the shared 10/min auth budget that would block sign-in from the same address, including the retry the user needs. /v1/auth/refresh now has its own per-IP budget (three times AUTH_RATE_LIMIT). The client-side prompt is Optional two-factor authentication for cloud accounts: enrolment and sign-in code step voltius#615.

Refactor

register, login and update_password each built an access token and a refresh token with their own map_err. They now share issue_session, and refresh shares access_token_for.

Tests

  • A refresh token from before a password change is refused, the one returned by the change works.
  • logout-all refuses every earlier refresh token, returns a working pair, and emits token_invalidated.
  • A token without ver refreshes until the first bump.
  • cargo test --all-targets (697 passed) and cargo clippy --all-targets -- -D warnings against Postgres 16.

One more in-process rate limiter, of the kind CLAUDE.md already lists; it moves to Postgres with the others.

Refresh tokens were stateless for their whole 90 days. Changing the
master password returned new tokens but left every earlier refresh
token working, and nothing could sign a device out from the server.

users.token_version is written into the refresh token as `ver` and
compared on every refresh. Changing the password bumps it, and
POST /v1/auth/logout-all bumps it on request. Both hand the caller a
pair minted at the new version and send token_invalidated so other
devices find out at once. Tokens issued before this decode as version 0
and stay valid until the first bump.

/v1/auth/refresh moves to its own per-IP budget. Released clients retry
a refused refresh every five seconds from the sync loop, which on the
shared auth budget would have locked the same address out of signing in.

The three copies of "access token + refresh token + map_err" in
register, login and password change are now one issue_session.
@kipavy
kipavy merged commit e1d1518 into main Oct 11, 2026
1 check passed
@kipavy
kipavy deleted the feat/refresh-token-revocation-104 branch October 11, 2026 13:35
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.

Refresh tokens cannot be revoked: add token_version

1 participant