163 lines
4.8 KiB
TypeScript
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;
|
|
},
|
|
};
|