// Atomic compare-and-swap for TOTP replay-window consumption. // // The old code path was: SELECT lastTotpAt → compare in JS → UPDATE. Two // concurrent requests with the same valid 6-digit code both see a stale // (or null) lastTotpAt, both pass the in-JS check, and both succeed. A // stolen TOTP (shoulder-surf, phishing-proxy replay) is therefore usable // twice within its 30 s window — the MFA design promise is violated. // // A single `updateMany` expresses the entire precondition in SQL: the WHERE // clause guarantees the row has not been consumed in the last 30 s, and the // SET sets the new timestamp. PostgreSQL's row-level lock serialises the two // racing writes; whichever commits second sees rows-affected = 0 and the // caller treats it as a replay. // // The 30 000 ms window matches the TOTP period (RFC 6238) — codes are // validated with `window: 1` so adjacent periods are still accepted; the // anti-replay check is the tighter per-code, per-user bound. // Intentionally loose structural type — Prisma's generated signature is a // deeply-inferred generic that does not simplify to a friendly shape; we only // need updateMany() with the documented args and a `{ count }` result. // Keeping the internal cast isolated here means every callsite stays // strictly typed. interface TotpConsumeDb { user: { updateMany: (args: { where: { id: string; OR: Array<{ lastTotpAt: Date | { lt: Date } | null }> }; data: { lastTotpAt: Date }; }) => Promise<{ count: number }>; }; } export async function consumeTotpWindow( db: { user: { updateMany: (...args: never[]) => unknown } }, userId: string, now: Date = new Date(), ): Promise { const typed = db as unknown as TotpConsumeDb; const windowStart = new Date(now.getTime() - 30_000); const result = await typed.user.updateMany({ where: { id: userId, OR: [{ lastTotpAt: null }, { lastTotpAt: { lt: windowStart } }], }, data: { lastTotpAt: now }, }); return result.count > 0; }