fix(19): IN-04 enforce single-use OIDC-link nonce to prevent state replay
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user