Files

163 lines
4.8 KiB
TypeScript

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;
},
};