feat(auth): invalidate access tokens on password reset/change (PMS-681) #460

Merged
nrupard merged 2 commits from feat/PMS-681-invalidate-tokens-on-password-change into main 2026-07-22 19:21:57 +02:00
Owner

What

Closes PMS-681: a password reset revoked refresh sessions but left the already-issued access token valid until its 1h TTL (the access path is stateless). Now a reset - and a self-service change - invalidate every already-issued access token immediately.

How

  • New column users.password_changed_at (migration 093_users_password_changed_at.sql), stamped to NOW() by reset_password and change_password.
  • The auth middleware rejects any access token whose iat predates password_changed_at. The check is folded into the per-request ensure_user_and_tenant_active (the JWT already carries iat, and the users row is already read per request, so this adds one PK-scoped scalar read under the tenant GUC). A NULL stamp means no cutoff, so tokens in flight survive the deploy. A failed check maps to unauthenticated -> 401, exactly like the existing "account not active" path.
  • Reset already called logout_all (refresh revoked); the stamp now kills the access token immediately too, so the user is fully logged out at once.

change_password (decided with Nate: log out everywhere)

change_password previously revoked nothing. It now stamps password_changed_at and calls logout_all, so after a self-service password change every session drops (including the current device) and the user signs in again.

Tests

  • The existing reset test now asserts password_changed_at is stamped.
  • access_token_rejected_after_password_change: a token issued before the stamp gets 401 (stamps a future instant for determinism).
  • change_password_logs_out_everywhere: the change revokes all sessions and stamps password_changed_at; the old password fails, the new one works.
  • Full cargo test --test auth passes against Postgres; cargo check --all-targets and clippy are clean.

Notes

Second granularity: a token minted in the same wall-clock second as the change is not rejected (strict iat < comparison). Negligible for the threat model (a stolen token was minted seconds to hours before the reset), and it avoids invalidating a legitimate new login issued in that same second.

#PMS-681

## What Closes PMS-681: a password reset revoked refresh sessions but left the already-issued access token valid until its 1h TTL (the access path is stateless). Now a reset - and a self-service change - invalidate every already-issued access token immediately. ## How - New column `users.password_changed_at` (migration `093_users_password_changed_at.sql`), stamped to `NOW()` by `reset_password` and `change_password`. - The auth middleware rejects any access token whose `iat` predates `password_changed_at`. The check is folded into the per-request `ensure_user_and_tenant_active` (the JWT already carries `iat`, and the users row is already read per request, so this adds one PK-scoped scalar read under the tenant GUC). A NULL stamp means no cutoff, so tokens in flight survive the deploy. A failed check maps to unauthenticated -> 401, exactly like the existing "account not active" path. - Reset already called `logout_all` (refresh revoked); the stamp now kills the access token immediately too, so the user is fully logged out at once. ## change_password (decided with Nate: log out everywhere) `change_password` previously revoked nothing. It now stamps `password_changed_at` and calls `logout_all`, so after a self-service password change every session drops (including the current device) and the user signs in again. ## Tests - The existing reset test now asserts `password_changed_at` is stamped. - `access_token_rejected_after_password_change`: a token issued before the stamp gets 401 (stamps a future instant for determinism). - `change_password_logs_out_everywhere`: the change revokes all sessions and stamps `password_changed_at`; the old password fails, the new one works. - Full `cargo test --test auth` passes against Postgres; `cargo check --all-targets` and clippy are clean. ## Notes Second granularity: a token minted in the same wall-clock second as the change is not rejected (strict `iat <` comparison). Negligible for the threat model (a stolen token was minted seconds to hours before the reset), and it avoids invalidating a legitimate new login issued in that same second. #PMS-681
feat(auth): invalidate access tokens on password reset/change (PMS-681)
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 36s
Check / fmt + clippy + build + tests (pull_request) Successful in 1m30s
Integration / integration tests (pull_request) Successful in 5m43s
acd0b58a4a
A password reset revoked refresh sessions (logout_all) but the access token, validated statelessly, lived out its 1h TTL, so after a compromise-recovery reset a stolen access token kept working for up to an hour. Close that window.

Add users.password_changed_at (migration 093), stamped to NOW() by reset_password and change_password. The auth middleware now rejects any access token whose iat predates it (one PK-scoped scalar read under the tenant GUC, folded into the existing per-request ensure_user_and_tenant_active; a NULL stamp imposes no cutoff, so tokens already in flight survive the deploy). A failed check maps to unauthenticated -> 401, the same as the existing not-active path.

Per the product decision, change_password now also logs the user out everywhere: it stamps password_changed_at and calls logout_all (previously it revoked nothing), so a self-service password change drops every session including the current device and the user signs in again.

tests/auth.rs: the reset test now asserts password_changed_at is stamped; access_token_rejected_after_password_change proves a pre-stamp token gets 401; change_password_logs_out_everywhere proves the change revokes sessions + stamps. Full auth suite green.

#PMS-681

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
refactor(auth): address PMS-681 review (fold password_changed_at into the user load, atomic revoke)
All checks were successful
E2E / Playwright against staging (pull_request) Successful in 41s
Check / fmt + clippy + build + tests (pull_request) Successful in 1m48s
Create release / Gate (release-branch merges only) (pull_request) Successful in 1s
Create release / Create release from merged PR (pull_request) Has been skipped
Integration / integration tests (pull_request) Successful in 10m11s
c28dff45bf
Follow-up to the code review of the PMS-681 change.

Finding 1 (hot-path query): password_changed_at is now a User field, loaded by get_user_by_id (#[sqlx(default)] on UserRow so the other three UserRow SELECTs skip it and default it to None). ensure_user_and_tenant_active returns the user it already loaded, and the middleware reuses it instead of re-querying. Net: the per-request legacy-auth path drops from three users reads (the check's extra scalar plus a duplicate load) to one. Removed the standalone ensure_token_after_password_change scalar.

Finding 2 (atomic revoke): reset_password and change_password now DELETE the user's sessions inside the same transaction as the password update, so a revoke failure rolls the whole change back (fail-closed) instead of leaving a usable refresh token behind. They no longer call logout_all after commit; logout_all stays for its tenant-scoping test (PMS-260) and future reuse.

Finding 3 (nit): the middleware-swallowed Forbidden message is now a terse internal string.

Behaviour unchanged; auth suite 30/30 green, exercising every UserRow SELECT path (login, list_users, forgot-password, reset, change) so the #[sqlx(default)] fallback is validated end to end.

#PMS-681

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard deleted branch feat/PMS-681-invalidate-tokens-on-password-change 2026-07-22 19:21:57 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!460
No description provided.