// Short-lived MFA tickets bridging the two login steps. // // When a user with TOTP enabled passes password auth, we don't create a session // yet — we hand back an opaque ticket. The second request (code or recovery // code) redeems the ticket to finish login. Tickets are single-use and expire // fast so a stolen ticket is near-useless. // // Tickets are bound to the issuing request's IP and User-Agent (hashed). A // stolen ticket replayed from a different origin redeems to null. This is // defense in depth against ticket exfiltration via a logged proxy, browser // extension, or shoulder-surf; it does not stop an attacker who is on the same // IP and UA. // // In-memory + single-instance, matching the existing login rate-limiter // (auth/rate-limit.js). Documented limitation: in a multi-instance deployment // the second step must hit the same node. Acceptable for Dragonflight's // one-mam-api-per-node shape; revisit if that changes. import { randomBytes, createHash } from 'node:crypto'; const TTL_MS = 5 * 60 * 1000; // 5 minutes to enter a code const tickets = new Map(); // id -> { userId, ipHash, uaHash, expiresAt } function sweep() { const now = Date.now(); for (const [id, t] of tickets) if (t.expiresAt <= now) tickets.delete(id); } function hashBinding(value) { return createHash('sha256').update(String(value || '')).digest('hex'); } export function issueTicket(userId, { ip, userAgent } = {}) { sweep(); const id = randomBytes(32).toString('hex'); tickets.set(id, { userId, ipHash: hashBinding(ip), uaHash: hashBinding(userAgent), expiresAt: Date.now() + TTL_MS, }); return id; } // Redeem (and consume) a ticket. Returns the userId, or null if missing, // expired, or the binding doesn't match the redeeming request. export function redeemTicket(id, { ip, userAgent } = {}) { if (!id) return null; const t = tickets.get(id); if (!t) return null; tickets.delete(id); // single-use — burn even on binding mismatch so a // wrong-binding probe can't be retried. if (t.expiresAt <= Date.now()) return null; // If a caller doesn't supply bindings (e.g. tests), accept — the issue side // controls whether bindings get recorded. if (ip !== undefined && t.ipHash !== hashBinding(ip)) return null; if (userAgent !== undefined && t.uaHash !== hashBinding(userAgent)) return null; return t.userId; }