Files
familysync/apps/api/src/auth/linkNonceStore.ts
T

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();
}