/** * Authentication Manager */ const REFRESH_URL = '/auth/refresh'; const PING_URL = '/auth/ping'; const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000; const PING_INTERVAL_MS = 60 * 1000; let tokenExpiresAt: number | null = null; let refreshPromise: Promise | null = null; export function recordExpiry(expiresIn: number | null | undefined): void { tokenExpiresAt = expiresIn != null ? Date.now() + expiresIn * 1000 : null; } function isTokenExpiredOrExpiring(): boolean { if (tokenExpiresAt === null) return false; return Date.now() >= tokenExpiresAt - TOKEN_EXPIRY_BUFFER_MS; } async function refreshToken(): Promise { if (refreshPromise) return refreshPromise; refreshPromise = fetch(REFRESH_URL, { method: 'POST', credentials: 'include' }) .then(async r => { if (!r.ok) return false; try { const body = await r.clone().json(); recordExpiry(body?.expires_in); } catch { /* ignore parse errors */ } return true; }) .catch(() => false) .finally(() => { refreshPromise = null; }); return refreshPromise; } /** * Ensures the token is fresh before a request fires. */ export async function ensureFreshToken(): Promise { if (isTokenExpiredOrExpiring()) { await refreshToken(); } } export interface SessionMonitorOptions { onLogout: () => void | Promise; } export function createSessionMonitor(options: SessionMonitorOptions) { let intervalId: ReturnType | null = null; async function ping(): Promise { // GET ping — no body, no CSRF needed, just confirm the session cookie is still valid const response = await fetch(PING_URL, { credentials: 'include' }).catch(() => null); if (!response?.ok) { // Session may be expired — attempt a token refresh before giving up const refreshed = await refreshToken(); if (!refreshed) { stop(); await options.onLogout(); } } } function start(): void { if (intervalId !== null) return; intervalId = setInterval(ping, PING_INTERVAL_MS); } function stop(): void { if (intervalId === null) return; clearInterval(intervalId); intervalId = null; } return { start, stop }; }