import { appConfig } from '../../config/appConfig'; /** * Two independent limits, and the session dies at whichever comes first: * an idle window the server resets on every authenticated call, and an * absolute ceiling it never extends however active the user is. */ interface SessionWindow { idleTimeoutMs: number; /** Epoch ms of the server's absolute expiry, or null when unknown. */ absoluteExpiryAt: number | null; } export type SessionExpiryReason = 'idle' | 'absolute'; /** * When we last exchanged an authenticated request with the server. This — not * anything the user did — is what the server's idle window is measured from, * so it is what expiry has to be judged on. */ let lastServerContactAt = Date.now(); /** * When the user last touched the screen. Tracked separately because most of * this app reads from fixtures: someone can tap around for five minutes without * a single authenticated call, and treating that as idle would sign them out * mid-use. Interaction cannot reset the server's timer by itself — only a call * can — so this drives a keep-alive rather than the expiry clock. */ let lastInteractionAt = Date.now(); let window: SessionWindow = { idleTimeoutMs: appConfig.security.sessionTimeoutMs, absoluteExpiryAt: null, }; export const sessionManager = { /** * Adopt the limits the server reported at sign-in. `idleTimeoutSeconds` and * `expiresAt` come straight off the login response, so the app never has to * guess and never drifts from what the backend will enforce. */ configure({ idleTimeoutSeconds, expiresAt, }: { idleTimeoutSeconds?: number; expiresAt?: string; }) { const absolute = expiresAt ? Date.parse(expiresAt) : NaN; window = { idleTimeoutMs: typeof idleTimeoutSeconds === 'number' && idleTimeoutSeconds > 0 ? idleTimeoutSeconds * 1000 : appConfig.security.sessionTimeoutMs, absoluteExpiryAt: Number.isFinite(absolute) ? absolute : null, }; lastServerContactAt = Date.now(); lastInteractionAt = Date.now(); }, clear() { window = { idleTimeoutMs: appConfig.security.sessionTimeoutMs, absoluteExpiryAt: null, }; lastServerContactAt = Date.now(); lastInteractionAt = Date.now(); }, /** An authenticated call completed, so the server's idle window just reset. */ touch() { lastServerContactAt = Date.now(); lastInteractionAt = Date.now(); }, /** * The user touched the screen. Deliberately does not reset the expiry clock: * the server has not heard from us, so pretending otherwise would leave the * app believing it is signed in while the next real request 401s. It only * makes `shouldKeepAlive()` true, and the keep-alive call is what actually * moves the server's window. */ noteInteraction() { lastInteractionAt = Date.now(); }, /** * Whether to spend a keep-alive call now. * * True only when the user has done something since we last spoke to the * server and the idle window is over halfway gone. The halfway threshold is * what keeps this to roughly one request per two and a half minutes instead * of one per tap. */ shouldKeepAlive() { if (lastInteractionAt <= lastServerContactAt) { return false; } // Nothing to preserve if the session is already gone, and no point // extending the idle window when the absolute cap is what will bite. if (this.hasExpired() || this.getAbsoluteRemainingMs() <= window.idleTimeoutMs / 2) { return false; } return this.getIdleDurationMs() >= window.idleTimeoutMs / 2; }, /** * Which limit will end the session first. The warning copy depends on it: * against the idle window carrying on using the app helps, and against the * absolute cap nothing does, so telling the user to "tap to stay signed in" * would be a lie. */ nearestLimit(): SessionExpiryReason { return this.getAbsoluteRemainingMs() <= this.getIdleRemainingMs() ? 'absolute' : 'idle'; }, getIdleTimeoutMs() { return window.idleTimeoutMs; }, getIdleDurationMs() { return Date.now() - lastServerContactAt; }, getIdleRemainingMs() { return Math.max(0, window.idleTimeoutMs - this.getIdleDurationMs()); }, getAbsoluteRemainingMs() { if (window.absoluteExpiryAt === null) { return Number.POSITIVE_INFINITY; } return Math.max(0, window.absoluteExpiryAt - Date.now()); }, /** Milliseconds until the session dies, whichever limit bites first. */ getRemainingMs() { return Math.min(this.getIdleRemainingMs(), this.getAbsoluteRemainingMs()); }, expiryReason(): SessionExpiryReason | null { if (this.getAbsoluteRemainingMs() <= 0) { return 'absolute'; } if (this.getIdleRemainingMs() <= 0) { return 'idle'; } return null; }, hasExpired() { return this.expiryReason() !== null; }, };