56 lines
2.1 KiB
TypeScript
56 lines
2.1 KiB
TypeScript
/**
|
|
* linkNonceStore.ts — single-use nonce store for the OIDC-link state (IN-04).
|
|
*
|
|
* POST /api/me/link-oidc mints a signed `state` JWT carrying a random `nonce` "to prevent
|
|
* replay". Previously the /callback handler never recorded or checked that nonce, so a
|
|
* captured state JWT was fully replayable within its 10-minute signature window — the nonce
|
|
* provided no actual protection (this underlies the BL-03 takeover concern).
|
|
*
|
|
* This in-memory store makes the nonce genuinely single-use:
|
|
* - registerLinkNonce(nonce, expEpochSeconds): called when the state is issued.
|
|
* - consumeLinkNonce(nonce): called on /callback; returns true exactly ONCE per nonce
|
|
* (and only while unexpired), false on replay / unknown / expired.
|
|
*
|
|
* In-memory is sufficient for a single-process household deployment (same scope as the
|
|
* loginAttempts limiter). Expired entries are swept opportunistically on each access so the
|
|
* map stays bounded. If this app ever runs multi-process, move this to Redis.
|
|
*/
|
|
|
|
// nonce → expiry (epoch ms). Presence means "issued and not yet consumed".
|
|
const issuedNonces = new Map<string, number>();
|
|
|
|
function sweepExpired(now: number): void {
|
|
for (const [nonce, expiresAt] of issuedNonces) {
|
|
if (now >= expiresAt) issuedNonces.delete(nonce);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a freshly-issued link nonce as valid until expEpochSeconds (the state JWT's exp).
|
|
*/
|
|
export function registerLinkNonce(nonce: string, expEpochSeconds: number): void {
|
|
const now = Date.now();
|
|
sweepExpired(now);
|
|
issuedNonces.set(nonce, expEpochSeconds * 1000);
|
|
}
|
|
|
|
/**
|
|
* Consume a link nonce. Returns true exactly once for a known, unexpired nonce; false for
|
|
* any replay, unknown, or expired nonce. Single-use: the entry is deleted on first success.
|
|
*/
|
|
export function consumeLinkNonce(nonce: string): boolean {
|
|
const now = Date.now();
|
|
sweepExpired(now);
|
|
const expiresAt = issuedNonces.get(nonce);
|
|
if (expiresAt === undefined || now >= expiresAt) return false;
|
|
issuedNonces.delete(nonce); // single-use
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Test-only: clear all issued nonces.
|
|
*/
|
|
export function _clearLinkNonces(): void {
|
|
issuedNonces.clear();
|
|
}
|