// 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;
}
