fix(auth): /auth/session must enforce session timeout symmetrically (#350 recurrence)

Third loop fix in the same /admin/login → /admin/dashboard → /admin/login
pattern as #355 and #363. Reported on v3.39.1-beta.0 — the loop returns
after a server restart or after an idle gap longer than the configured
session timeout.

## Root cause (server)

`sessionTimeoutMiddleware` is mounted on `/api/admin` (server.js:411). It
rejects with `401 SESSION_TIMEOUT` when either:
  - the in-memory `lastActivity` for the token is older than the timeout, or
  - this is the first request with this token AND the token's `iat` is
    older than the timeout (post-restart guard).

`/auth/session` lives under `/api/auth/session`, NOT under `/api/admin`,
so the middleware never runs for it. Result: an idle/old-iat admin token
returns `valid: true` from `/auth/session` while every protected
endpoint immediately rejects it with `401 SESSION_TIMEOUT`. Frontend's
401 interceptor hard-redirects to `/admin/login`, `/auth/session` says
valid again, loop closes — exact same shape as the previous two
asymmetries the symmetry pass missed.

Fix: add a non-mutating `isSessionExpired(token, decoded)` helper to
`middleware/sessionTimeout.js` that reads the same in-memory map and
applies the same lastActivity / iat-vs-timeout logic as the middleware,
without updating the map (the middleware is the only place that records
activity; `/auth/session` is read-only by design). `/auth/session`
calls the helper for `decoded.type === 'admin'` after the existing
admin-existence and password-change checks. Same try/catch fall-through
pattern as the prior fixes so a missing/broken helper doesn't fail-closed
during early bootstrap or in test stubs.

## Root cause (client race amplifying the loop)

Even with the server fix, the previous `useSessionTimeout` hook called
`AdminAuthContext.logout()` which dispatches `POST /auth/logout`
fire-and-forget AND has its own `finally { window.location.href }`,
then immediately set `window.location.href = '/admin/login?session=expired'`
on top. Two consequences:
  - The cookie wasn't reliably cleared before the new page loaded —
    if any /auth/session asymmetry slipped through, the loop replayed
    inside the same tab. New-tab and "refresh several times" "fixes"
    were just the logout request eventually completing.
  - Two redirects raced; sometimes the `?session=expired` query was
    dropped, breaking the login-page toast.

Fix: rewrite the hook to (a) await `POST /auth/logout` so the cookie
is guaranteed cleared, (b) clear `sessionStorage.admin_user` directly
instead of going through AdminAuthContext.logout (which has the
side-effect redirect we don't want), and (c) navigate exactly once
with the `?session=expired` query.

## Tests

- `__tests__/routes/authSession.symmetry.test.js` — 4 new cases under
  a `session-timeout symmetry` describe block: helper says expired →
  valid:false; helper says active → valid:true; helper not called for
  gallery tokens; helper throws → fall through to valid:true (defensive).
  Existing 9 tests still pass (mock now includes
  `isSessionExpired: jest.fn(() => Promise.resolve(false))` as the
  default).
- `__tests__/middleware/sessionTimeout.isSessionExpired.test.js` — 7
  new unit tests for the helper itself: fresh token / old-iat /
  recently-active / null-input / no-mutation / 60-min default
  boundary cases.

20 cases total, all green. Lint clean on every touched file.
This commit is contained in:
Paul Nothaft
2026-05-05 00:12:01 +02:00
parent 045620e999
commit b106da1ede
5 changed files with 286 additions and 12 deletions
+32 -3
View File
@@ -134,6 +134,34 @@ async function sessionTimeoutMiddleware(req, res, next) {
}
}
// Non-mutating timeout check used by /auth/session (auth.js) so the session
// endpoint enforces the same timeout the protected /api/admin endpoints
// already enforce via sessionTimeoutMiddleware. Without this, /auth/session
// returns valid:true for a token that protected endpoints reject with
// 401 SESSION_TIMEOUT, producing the /admin/login → /admin/dashboard →
// /admin/login redirect loop reported on v3.39.1-beta.0 (issue #350).
//
// Mirrors the middleware's logic exactly:
// - If we have an in-memory lastActivity for this token, return whether
// the gap exceeds the timeout.
// - Otherwise (post-restart, or first request with this token), return
// whether the token's iat is older than the timeout — same post-restart
// guard the middleware uses.
//
// Does NOT update the in-memory map. The middleware is the only place that
// tracks activity; /auth/session is read-only by design.
async function isSessionExpired(token, decoded) {
if (!token || !decoded || !decoded.id) return false;
const now = Date.now();
const timeout = await getSessionTimeout();
const lastActivity = sessions.get(token);
if (lastActivity) {
return (now - lastActivity) > timeout;
}
const tokenIssuedAt = (decoded.iat || 0) * 1000;
return (now - tokenIssuedAt) > timeout;
}
// Function to end a session
function endSession(token) {
sessions.delete(token);
@@ -153,8 +181,9 @@ function getActiveSessions() {
return active;
}
module.exports = {
sessionTimeoutMiddleware,
module.exports = {
sessionTimeoutMiddleware,
isSessionExpired,
endSession,
getActiveSessions
getActiveSessions
};
+16
View File
@@ -534,6 +534,22 @@ router.get('/session', async (req, res) => {
return res.json({ valid: false, error: 'Token invalid due to password change' });
}
}
// Mirror the session-timeout check that sessionTimeoutMiddleware
// enforces on every /api/admin endpoint. Without this, /auth/session
// returns valid:true for an idle/old-iat token that protected
// endpoints reject with 401 SESSION_TIMEOUT — the same redirect-loop
// shape as the issuer-claim and password-change asymmetries (issue
// #350 recurrence on v3.39.1-beta.0).
try {
const { isSessionExpired } = require('../middleware/sessionTimeout');
if (await isSessionExpired(token, decoded)) {
return res.json({ valid: false, error: 'Session expired' });
}
} catch (timeoutErr) {
// Helper lookup failed (test stub may not export it) — fall through
// and trust the token. Real deployments always have the middleware.
}
} else if (decoded.type === 'gallery') {
try {
const event = await db('events')