feat: implement cross-tab idle session timeout and add request collapsing to token refresh logic.

This commit is contained in:
estifanos
2026-08-19 09:29:02 +00:00
parent 439a4963ec
commit 4e2f63d4df
8 changed files with 147 additions and 14 deletions

View File

@@ -3,6 +3,7 @@ export type { AuthConfigValue } from "./lib/AuthConfig";
export { AuthShell, BrandMark } from "./lib/components/AuthShell";
export { ProtectedRoute } from "./lib/components/ProtectedRoute";
export { AuthBootstrap } from "./lib/components/AuthBootstrap";
export { useIdleTimer } from "./lib/hooks/useIdleTimer";
export { LoginPage } from "./lib/pages/LoginPage";
export { SignupPage } from "./lib/pages/SignupPage";
export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage";

View File

@@ -54,9 +54,13 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
response = await fetch(`${BASE_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${fresh}` },
});
} catch {
// No refresh token, or the server rejected it — fall through to
// the logout below.
} catch (err) {
// Only the server rejecting the refresh token ends the session —
// same rule as the API layer's 401 handler. A 502 or a network
// blip during refresh keeps the stored session; the screens
// surface their own errors.
if (!(err as { sessionExpired?: boolean })?.sessionExpired) return;
// Rejected — fall through to the logout below.
}
}

View File

@@ -0,0 +1,76 @@
import { useEffect, useRef } from 'react';
const ACTIVITY_EVENTS = [
'mousedown',
'mousemove',
'keydown',
'scroll',
'touchstart',
'click',
] as const;
/**
* Fires `onIdle` once no activity event has fired for `timeoutMs` — the
* "left the desk" auto-logout for a govt app handling sensitive records.
*
* `onIdle` is read through a ref rather than a `useEffect` dependency: the
* caller typically passes a fresh closure every render (it captures
* `dispatch`, `navigate`, current user), and depending on it directly would
* tear down and re-add six window listeners — and rearm the timer to a full
* 15 minutes — on every unrelated re-render, not just real activity.
*/
export function useIdleTimer(timeoutMs: number, onIdle: () => void) {
const onIdleRef = useRef(onIdle);
onIdleRef.current = onIdle;
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
let lastReset = 0;
// localStorage is origin-scoped, so every tab of this app shares it and
// the sibling app (other port/domain) does not.
const ACTIVITY_KEY = 'ema-last-activity';
function fire() {
// This tab sat idle, but a sibling tab may have been busy the whole
// time — logging out here would clear the shared cookies and kill that
// tab mid-work. Trust the newest activity stamp any tab wrote.
let last = 0;
try {
last = Number(localStorage.getItem(ACTIVITY_KEY)) || 0;
} catch {
/* storage blocked — fall back to this tab's own timer */
}
const remaining = last + timeoutMs - Date.now();
if (remaining > 1000) {
timer = setTimeout(fire, remaining);
} else {
onIdleRef.current();
}
}
function reset() {
// mousemove fires dozens of times a second; only rearm once a second
// so it isn't clearing/setting a timeout on every pixel of movement.
const now = Date.now();
if (now - lastReset < 1000) return;
lastReset = now;
try {
localStorage.setItem(ACTIVITY_KEY, String(now));
} catch {
/* ignore */
}
clearTimeout(timer);
timer = setTimeout(fire, timeoutMs);
}
reset();
ACTIVITY_EVENTS.forEach((event) => window.addEventListener(event, reset));
return () => {
ACTIVITY_EVENTS.forEach((event) =>
window.removeEventListener(event, reset),
);
clearTimeout(timer);
};
}, [timeoutMs]);
}

View File

@@ -6,6 +6,7 @@ import type {
LoginPayload,
} from "../types/auth.types";
import { authStorage } from "../utils/auth-storage";
import { setSignedOut } from "../utils/refresh-token";
const initialState: AuthState = {
user: null,
@@ -19,6 +20,7 @@ const authSlice = createSlice({
initialState,
reducers: {
loginSuccess(state, action: PayloadAction<LoginPayload>) {
setSignedOut(false);
state.token = action.payload.token;
state.isAuthenticated = true;
authStorage.setToken(action.payload.token);
@@ -37,6 +39,8 @@ const authSlice = createSlice({
authStorage.removeProfile();
},
logout(state) {
// Before clearing storage, so an in-flight refresh can't repopulate it.
setSignedOut(true);
state.user = null;
state.token = null;
state.isAuthenticated = false;

View File

@@ -21,6 +21,17 @@ const sessionExpired = (message: string) =>
let inFlight: Promise<string> | null = null;
let signedOut = false;
/**
* Set on logout, cleared on login. A refresh that was already in flight when
* the user (or the idle timer) signed out must not write its response back
* into storage — that would silently re-authenticate an unattended desk.
*/
export function setSignedOut(v: boolean) {
signedOut = v;
}
/**
* Concurrent 401s must share one refresh. A page fires several requests at
* once; without this each one POSTs the same refresh token, the server rotates
@@ -28,13 +39,34 @@ let inFlight: Promise<string> | null = null;
* winner just renewed.
*/
export function refreshAccessToken(): Promise<string> {
inFlight ??= runRefresh().finally(() => {
inFlight ??= acquireAndRefresh().finally(() => {
inFlight = null;
});
return inFlight;
}
/**
* Cross-tab guard on top of the in-tab one: cookies are shared per origin, so
* two tabs expiring together would both POST the same rotating refresh token
* and the loser would tear down the session the winner just renewed. A Web
* Lock makes the second tab wait; if the first tab already refreshed while it
* waited, the fresh token is sitting in storage and no request is needed.
*/
async function acquireAndRefresh(): Promise<string> {
if (typeof navigator === "undefined" || !navigator.locks) {
// Old Safari / test env — in-tab de-dup still applies.
return runRefresh();
}
const tokenBefore = authStorage.getToken();
return navigator.locks.request("ema-token-refresh", async () => {
const current = authStorage.getToken();
if (current && current !== tokenBefore) return current;
return runRefresh();
});
}
async function runRefresh(): Promise<string> {
if (signedOut) throw new Error("Signed out");
const refreshToken = authStorage.getRefreshToken();
if (!refreshToken) throw sessionExpired("No refresh token available");
@@ -61,6 +93,9 @@ async function runRefresh(): Promise<string> {
}
const data: RefreshResponse = await response.json();
// Deliberately NOT sessionExpired: the user already signed out, so there is
// no session left to end — just refuse to resurrect it.
if (signedOut) throw new Error("Signed out during refresh");
authStorage.setToken(data.token);
if (data.refreshToken) authStorage.setRefreshToken(data.refreshToken);
return data.token;