Visitar URL original
security: revoke sessions when an account is deactivated, demoted or its password changes by erseco · Pull Request #2594 · exelearning/exelearning · GitHub
Skip to content

security: revoke sessions when an account is deactivated, demoted or its password changes - #2594

Open
erseco wants to merge 3 commits into
mainfrom
hotfix/session-revocation
Open

erseco wants to merge 3 commits into
mainfrom
hotfix/session-revocation

Conversation

@erseco

@erseco erseco commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator

Problem

Session JWTs are valid for seven days, and every route trusted their claims without looking at the account again. As a result:

  • deactivating or deleting a user did not end their existing sessions;
  • removing ROLE_ADMIN left the admin panel reachable with the old token;
  • resetting a password did not log out sessions opened with the old one;
  • CAS and OpenID logins issued sessions for deactivated accounts (only the password login checked is_active);
  • GET /api/yjs/debug/:projectUuid/ws-url minted a fresh token from any valid token, so a session could be renewed indefinitely.

How other projects handle it

Approach Used by Cost here
Reload the user on every request and log out if it changed (roles, password, enabled) Symfony (refreshUser / hasUserChanged), Laravel session guard One primary-key lookup per request, no schema change
Keyed hash of the password hash stored in the session, compared on each request Django (get_session_auth_hash), Laravel AuthenticateSession A claim in the token, no schema change
token_version / password_changed_at column compared with the token many JWT setups Migration on three SQL dialects plus increments at every write site

This PR combines the first two: no migration, and it covers deletion, deactivation, demotion and password changes with one check.

Changes

  • src/services/session-revocation.ts:
    • getSessionRevocationReason(payload) rejects a token when any of these holds:
      • the account is missing or inactive;
      • the token claims a role the account no longer has (gaining a role keeps the session);
      • the token's pwh no longer matches the current password hash;
      • for an impersonation token, the impersonating administrator is no longer an active admin.
    • sessionRevocationGuard() is mounted once in src/index.ts, after the static-file hook and before the maintenance check. It removes a revoked token from the Authorization header or the auth cookie before any route runs, so all ~15 existing verification sites (route plugins, API v1, maintenance bypass) treat the request as anonymous with no per-route changes. Forged or expired tokens are left for the routes to reject as before.
    • Results are cached per token for 5 seconds, because every static asset request carries the auth cookie. A revocation therefore applies within 5 seconds on each instance.
    • verifySessionToken() is now the Yjs WebSocket's default verifier (its token travels in the query string, not in a header).
  • Login tokens (password, form login, CAS, OpenID, guest, offline, impersonation) now carry pwh, an HMAC of the stored password hash keyed with the JWT secret. It reveals nothing about the hash.
  • PATCH /api/user/password re-signs the caller's own session with the new fingerprint, so changing your own password does not log you out (as Django's update_session_auth_hash does). Other sessions are revoked.
  • CAS and OpenID callbacks return 403 for deactivated accounts, matching the password login.
  • The yjs-debug ws-url token copies the caller's claims (isGuest, authMethod, pwh) instead of hard-coding them, and its expiry is capped at the caller's own exp.

Compatibility

  • Tokens issued before this change have no pwh. They are still checked for account existence, active state and roles, but a password change does not revoke them; they expire within seven days. CLI tokens from jwt:generate behave the same way, but --raw-sub tokens with an email sub are now rejected.
  • The WebSocket token embedded in the workarea page has no pwh on purpose, so changing your own password does not break the editor's reconnects.
  • A demoted user is logged out and simply logs in again with the new roles. Gaining a role keeps the session.

Not covered (follow-up)

  • WebSocket connections that are already open are not closed when an account is revoked; the check applies when a socket connects or reconnects. Closing live sockets across instances is a separate change (multi-instance revocation).

Tests

  • src/services/session-revocation.spec.ts: 32 cases at 100% coverage of the new module. They cover:
    • every revocation reason;
    • malformed subjects (missing, zero, negative, fractional, non-numeric);
    • a re-activated user getting a valid session again;
    • unreadable stored roles;
    • forged tokens (rejected without any database lookup);
    • cookie stripping that keeps the other cookies;
    • Bearer stripping;
    • the guard in front of the real withJwtAuth/requireAuth/requireAdmin (deactivated → 401, demoted admin → 401 on admin routes);
    • cache expiry.
  • auth.spec.ts: login token carries pwh; CAS and OpenID refuse deactivated accounts.
  • user.spec.ts: a password change re-signs the session with the new fingerprint.
  • admin.spec.ts: the impersonation token carries the target's pwh and impersonatedBy.
  • yjs-debug.spec.ts: the ws-url token never outlives the caller and copies its claims.
  • yjs-websocket.spec.ts: the default WebSocket verifier goes through the revocation check.

Verification

  • make fix: exit 0; the 44 warnings were already there before this change, none in changed lines.
  • make test-unit: exit 0, 9235 pass, 0 fail.
  • make test-integration: exit 0, 770 pass, 0 fail.
  • Smoke test against a real server (bun src/index.ts, SQLite) with the same cookie: logged-in session returns 200; after deactivating the user, /api/auth/user with the cookie and /api/v1/projects with the Bearer token both return 401 while /login still renders (200); after re-activating, 200 again; after changing the stored password hash, 401.
  • E2E not run locally; CI runs it.

…its password changes

Session JWTs live for seven days and were never re-checked against the
account, so deactivating, deleting or demoting a user, or resetting their
password, had no effect until the token expired.

- Add services/session-revocation: every verified session token is checked
  against the current account row (exists, active, no role it has lost,
  password fingerprint unchanged, impersonating admin still an active admin).
- A global onRequest guard removes revoked tokens from the Authorization
  header and auth cookie, so every route treats the request as anonymous.
  Results are cached per token for 5 seconds.
- The Yjs WebSocket verifies its query token through the same check.
- Login tokens carry pwh, a keyed fingerprint of the password hash; changing
  your own password re-signs the current session.
- CAS and OpenID logins refuse deactivated accounts.
- The yjs-debug ws-url token copies the caller's claims and never outlives
  the caller's token.
@erseco
erseco requested a review from ignaciogros October 21, 2026 05:17
@erseco erseco added the bug Something isn't working label Oct 21, 2026
@erseco erseco self-assigned this Oct 21, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Deploying exelearning with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9a0c762
Status: ✅  Deploy successful!
Preview URL: https://4e562b97.exelearning.pages.dev
Branch Preview URL: https://hotfix-session-revocation.exelearning.pages.dev

View logs

@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.26%. Comparing base (bce8f99) to head (9a0c762).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff            @@
##             main    #2594    +/-   ##
========================================
  Coverage   92.25%   92.26%            
========================================
  Files         479      480     +1     
  Lines       89517    89626   +109     
========================================
+ Hits        82584    82690   +106     
- Misses       6933     6936     +3     
Flag Coverage Δ
frontend 92.26% <100.00%> (+<0.01%) ⬆️
unittests 92.26% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@codecov

codecov Bot commented Oct 21, 2026

Copy link
Copy Markdown

Bundle Report

Bundle size has no change ✅

@erseco erseco added this to the v4.0.7 milestone Oct 29, 2026
The deactivated-account test built the callback request by hand, from before
main required the oidc_state cookie and a matching nonce, so the handler now
rejects it with 400 before it ever looks the account up.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant