From 030592ac2316b0ccf04de1132826d87836857703 Mon Sep 17 00:00:00 2001 From: saubyk <39208279+saubyk@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:41:25 -0700 Subject: [PATCH] Address review: fix logout -> re-login under session-bound CSRF tokens Session-bound tokens broke re-login after logout: logoutUser destroys the session, but the SPA navigated to the login page without a document reload, so the surviving _csrf/XSRF-TOKEN cookies stayed bound to the destroyed session id and the next login POST failed with 403 until a manual refresh. Hit both manual logout and the idle-timer auto-logout. Two coordinated fixes: 1. Frontend: the logout effect now performs a full document navigation to the login page (after the server logout completes, so the request is not aborted by the reload), which re-runs the handshake and mints a token bound to the fresh session. The logout reason previously travelled on the NgRx action stream, which cannot survive a reload - it is now handed over via sessionStorage (set after clearAll) and picked up and cleared by the login component. The SSO branch is unchanged (it already left the document). 2. Backend: the EBADCSRFTOKEN error path now re-mints the token for the current session before responding 403, so any client holding a stale token (e.g. after a server restart rotates the boot secret) self-heals on retry instead of looping on 403. Verified on the fixture: reviewer's repro now shows login 200 -> logout 200 -> stale-token login 403 (binding intact) with re-minted cookies on the 403 -> retry 200; and the reload path (fresh GET / after logout, what the full navigation does) logs in on the first attempt. Both API suites, the CSRF battery, rtl.effects specs and the full frontend suite pass; frontend and backend artifacts rebuilt. --- backend/utils/app.js | 14 ++++++++++++-- backend/utils/csrf.js | 4 ++++ frontend/index.html | 2 +- frontend/main.408918db3309e4a9.js | 1 + frontend/main.5217805f96f400a7.js | 1 - server/utils/app.ts | 13 +++++++++++-- server/utils/csrf.ts | 5 +++++ .../shared/components/login/login.component.ts | 10 +++++++++- src/app/store/rtl.effects.ts | 17 +++++++++++++++-- 9 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 frontend/main.408918db3309e4a9.js delete mode 100644 frontend/main.5217805f96f400a7.js diff --git a/backend/utils/app.js b/backend/utils/app.js index 7ca996a8..8cd5e096 100644 --- a/backend/utils/app.js +++ b/backend/utils/app.js @@ -46,12 +46,12 @@ export class ExpressApplication { res.sendFile(join(this.directoryName, '../..', 'frontend', 'index.html')); }); this.app.use((err, req, res, next) => { - this.handleApplicationErrors(err, res); + this.handleApplicationErrors(err, req, res); next(); }); this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'App', msg: 'Application Routes Set' }); }; - this.handleApplicationErrors = (err, res) => { + this.handleApplicationErrors = (err, req, res) => { switch (err.code) { case 'EACCES': this.logger.log({ selectedNode: this.common.selectedNode, level: 'ERROR', fileName: 'App', msg: 'Server requires elevated privileges' }); @@ -66,6 +66,16 @@ export class ExpressApplication { res.status(401).send('Server is down/locked.'); break; case 'EBADCSRFTOKEN': + // Re-mint the token for the current session so a client retry succeeds + // (the stale one may be bound to a destroyed session or rotated secret). + try { + const csrfToken = CSRF.reMintToken(req, res); + res.cookie('XSRF-TOKEN', csrfToken); + res.setHeader('XSRF-TOKEN', csrfToken); + } + catch (csrfError) { + this.logger.log({ selectedNode: this.common.selectedNode, level: 'ERROR', fileName: 'App', msg: 'CSRF Token Re-Mint Failed', error: csrfError }); + } this.logger.log({ selectedNode: this.common.selectedNode, level: 'ERROR', fileName: 'App', msg: 'Invalid CSRF token. Form tempered.' }); res.status(403).send('Invalid CSRF token, form tempered.'); break; diff --git a/backend/utils/csrf.js b/backend/utils/csrf.js index 0a48cd6d..0f98607d 100644 --- a/backend/utils/csrf.js +++ b/backend/utils/csrf.js @@ -21,6 +21,10 @@ class CSRF { req.headers['x-csrf-token'] || req.headers['x-xsrf-token'] }); this.csrfProtection = this.doubleCsrfUtilities.doubleCsrfProtection; + // Force-mints a fresh token for the current session, discarding any token + // cookie bound to a previous session or boot secret (used by the + // EBADCSRFTOKEN error path in app.ts so a client retry succeeds). + this.reMintToken = (req, res) => this.doubleCsrfUtilities.generateCsrfToken(req, res, { overwrite: true }); } mount(app) { this.logger.log({ selectedNode: this.common.selectedNode, level: 'INFO', fileName: 'CSRF', msg: 'Setting up CSRF..' }); diff --git a/frontend/index.html b/frontend/index.html index db0b1c8a..13fbd5c1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -15,5 +15,5 @@