b106da1ede
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.
64 lines
2.5 KiB
TypeScript
64 lines
2.5 KiB
TypeScript
import { useEffect, useCallback } from 'react';
|
|
import { api } from '../config/api';
|
|
|
|
// Hook to handle session timeout
|
|
export const useSessionTimeout = () => {
|
|
const handleSessionTimeout = useCallback((error: any) => {
|
|
if (error?.response?.data?.code === 'SESSION_TIMEOUT') {
|
|
// Defense-in-depth for the redirect-loop bug class (issue #350):
|
|
// the previous implementation called the AdminAuthContext logout()
|
|
// (which dispatches POST /auth/logout fire-and-forget AND has its
|
|
// own finally-block redirect) and then set window.location.href
|
|
// immediately. The cookie wasn't reliably cleared before the page
|
|
// reloaded — if the next /auth/session call read the stale cookie
|
|
// AND any server asymmetry returned valid:true for it, the redirect
|
|
// loop replayed inside the same tab. Two-tab/multi-refresh "fixes"
|
|
// were just the logout request eventually completing in time.
|
|
//
|
|
// We now (a) await the server-side logout so the cookie is
|
|
// guaranteed cleared before the new page loads, (b) clear
|
|
// sessionStorage directly so we don't depend on AdminAuthContext's
|
|
// logout (which has the side-effect redirect we don't want), and
|
|
// (c) navigate exactly once with the ?session=expired query the
|
|
// login page reads to show the "your session expired" toast.
|
|
void (async () => {
|
|
try {
|
|
await api.post('/auth/logout');
|
|
} catch {
|
|
// Ignore; the cookie may already be invalid server-side. The
|
|
// redirect below still happens and the next /auth/session call
|
|
// will return 401 (no token) either way.
|
|
}
|
|
try {
|
|
sessionStorage.removeItem('admin_user');
|
|
} catch {
|
|
// sessionStorage can throw in private-browsing modes — ignore.
|
|
}
|
|
window.location.href = '/admin/login?session=expired';
|
|
})();
|
|
return true;
|
|
}
|
|
return false;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
// Add response interceptor to handle session timeout
|
|
const interceptor = api.interceptors.response.use(
|
|
response => response,
|
|
error => {
|
|
if (handleSessionTimeout(error)) {
|
|
// Don't propagate the error if it was a session timeout
|
|
return Promise.reject(new Error('Session expired'));
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
|
|
// Clean up interceptor on unmount
|
|
return () => {
|
|
api.interceptors.response.eject(interceptor);
|
|
};
|
|
}, [handleSessionTimeout]);
|
|
|
|
return { handleSessionTimeout };
|
|
}; |