Source: routes/auth.js

/**
 * @module routes/auth
 * @description Authentication routes for email/password and OAuth (GitHub, Google).
 *
 * ### Endpoints (INF-005: all under `/api/v1/auth/`)
 * | Method | Path                              | Description                          |
 * |--------|-----------------------------------|--------------------------------------|
 * | POST   | `/api/v1/auth/register`           | Email/password registration          |
 * | POST   | `/api/v1/auth/login`              | Email/password sign-in               |
 * | POST   | `/api/v1/auth/logout`             | Token revocation + cookie clear      |
 * | POST   | `/api/v1/auth/refresh`            | Refresh session (extend cookie TTL)  |
 * | GET    | `/api/v1/auth/me`                 | Return current user from cookie      |
 * | GET    | `/api/v1/auth/export`             | Export user-owned account data (SEC-003) |
 * | DELETE | `/api/v1/auth/account`            | Delete account + owned data (SEC-003) |
 * | GET    | `/api/v1/auth/verify`             | Verify email via token (SEC-001)     |
 * | POST   | `/api/v1/auth/resend-verification`| Resend verification email (SEC-001)  |
 * | POST   | `/api/v1/auth/forgot-password`    | Request a password reset token       |
 * | POST   | `/api/v1/auth/reset-password`     | Reset password using a valid token   |
 * | GET    | `/api/v1/auth/github/callback`    | GitHub OAuth token exchange          |
 * | GET    | `/api/v1/auth/google/callback`    | Google OAuth token exchange          |
 *
 * ### Security measures
 * - Passwords hashed with scrypt (64-byte key, 16-byte random salt)
 * - JWT stored in HttpOnly; Secure; SameSite=Strict cookie — never in localStorage
 * - A companion `token_exp` cookie (Non-HttpOnly) exposes only the exp timestamp
 *   so the frontend can proactively warn before expiry without ever touching the JWT
 * - JWT signed with HS256, 8-hour expiry
 * - Rate limiting: separate per-endpoint buckets (login: 10, forgot/reset: 5 per IP per 15 min)
 * - Revoked tokens kept in an in-memory Map (production: use Redis — see ENH-002)
 * - Password reset tokens persisted in DB table `password_reset_tokens` (migration 003)
 * - Input validation and sanitisation on every endpoint
 * - OAuth state parameter validated on the frontend to prevent CSRF
 * - CSRF double-submit cookie protection on all mutating endpoints (via appSetup.js)
 * - No sensitive data (passwords, raw OAuth tokens, JWT strings) returned to client
 */

import express from "express";
import crypto from "crypto";
import * as userRepo from "../database/repositories/userRepo.js";
import * as resetTokenRepo from "../database/repositories/passwordResetTokenRepo.js";
import * as verificationTokenRepo from "../database/repositories/verificationTokenRepo.js";
import * as workspaceRepo from "../database/repositories/workspaceRepo.js";
import * as accountRepo from "../database/repositories/accountRepo.js";
import * as projectRepo from "../database/repositories/projectRepo.js";
import * as webauthnRepo from "../database/repositories/webauthnRepo.js";
import { formatLogLine } from "../utils/logFormatter.js";
import { logActivity } from "../utils/activityLogger.js";
import { evaluateMfaEnforcement } from "../utils/mfaEnforcement.js";
import { encryptString, decryptString } from "../utils/credentialEncryption.js";
import { stopSchedule } from "../scheduler.js";
import { sendVerificationEmail } from "../utils/emailSender.js";
import { buildJwtPayload, buildUserResponse } from "../utils/authWorkspace.js";
import { SYSTEM_WORKSPACE_ID } from "../constants/systemWorkspace.js";
import { cookieSameSite } from "../middleware/appSetup.js";
import {
  signJwt, getJwtSecret, revokedTokens,
  requireUser, AUTH_COOKIE,
} from "../middleware/authenticate.js";

/**
 * Backward-compatible alias.  All files that do
 *   `import { requireAuth } from "./routes/auth.js"`
 * continue to work — `requireUser` is the same JWT cookie → bearer → query
 * middleware that `requireAuth` used to be.
 */
export const requireAuth = requireUser;

// ─── Cookie helpers ───────────────────────────────────────────────────────────

/** Expiry hint cookie — Non-HttpOnly so the frontend can read the `exp` timestamp. */
export const EXP_COOKIE      = "token_exp";
/** JWT TTL in seconds (8 hours). Must match signJwt default. */
export const JWT_TTL_SEC     = 8 * 60 * 60;

/**
 * Set the HttpOnly auth cookie + a readable expiry hint cookie on a response.
 * Called after every successful authentication (login, OAuth, refresh, workspace switch).
 *
 * @param {Object} res       - Express response object.
 * @param {string} token     - The signed JWT string.
 * @param {number} expSec    - Unix timestamp of token expiry (seconds).
 */
export function setAuthCookie(res, token, expSec) {
  const maxAge  = JWT_TTL_SEC;
  const sameSite = cookieSameSite();

  // Use appendHeader so we don't overwrite the _csrf cookie that the
  // CSRF middleware may have already queued on this response via setHeader.
  // Primary cookie: HttpOnly prevents JS from ever reading the JWT.
  // SameSite policy is determined by cookieSameSite() — Strict for same-origin,
  // None; Secure for cross-origin (GitHub Pages + Render).
  res.appendHeader("Set-Cookie",
    `${AUTH_COOKIE}=${token}; Path=/; HttpOnly; Max-Age=${maxAge}${sameSite}`
  );
  // Expiry hint: NOT HttpOnly — frontend reads it for proactive expiry UX.
  // Contains only the numeric exp timestamp, not the token.
  res.appendHeader("Set-Cookie",
    `${EXP_COOKIE}=${expSec}; Path=/; Max-Age=${maxAge}${sameSite}`
  );
}

/**
 * Clear both auth cookies, effectively logging the user out client-side.
 * @param {Object} res - Express response object.
 */
function clearAuthCookies(res) {
  const sameSite = cookieSameSite();
  res.appendHeader("Set-Cookie", `${AUTH_COOKIE}=; Path=/; HttpOnly; Max-Age=0${sameSite}`);
  res.appendHeader("Set-Cookie", `${EXP_COOKIE}=; Path=/; Max-Age=0${sameSite}`);
}


const router = express.Router();

// ─── Helpers ─────────────────────────────────────────────────────────────────

/**
 * Hash a password using Node.js scrypt (no native addon needed).
 * Generates a 16-byte random salt and derives a 64-byte key.
 *
 * @param   {string} password - The plaintext password to hash.
 * @returns {Promise<string>}   Format: `"<hex-salt>:<hex-derived-key>"`.
 * @private
 */
async function hashPassword(password) {
  const salt = crypto.randomBytes(16).toString("hex");
  const derived = await new Promise((res, rej) =>
    crypto.scrypt(password, salt, 64, (err, key) => (err ? rej(err) : res(key)))
  );
  return `${salt}:${derived.toString("hex")}`;
}

/**
 * Verify a plaintext password against a stored hash.
 * Uses constant-time comparison to prevent timing attacks.
 *
 * @param   {string}  password - The plaintext password to verify.
 * @param   {string}  stored   - The stored hash in `"<salt>:<key>"` format.
 * @returns {Promise<boolean>}   `true` if the password matches.
 * @private
 */
async function verifyPassword(password, stored) {
  const [salt, hash] = stored.split(":");
  const derived = await new Promise((res, rej) =>
    crypto.scrypt(password, salt, 64, (err, key) => (err ? rej(err) : res(key)))
  );
  // Constant-time comparison to prevent timing attacks
  return crypto.timingSafeEqual(Buffer.from(hash, "hex"), derived);
}

// signJwt, verifyJwt, getJwtSecret, revokedTokens — imported from
// middleware/authenticate.js above.  No duplicated implementations here.

// Password reset tokens are stored in the `password_reset_tokens` DB table
// (migration 003). The token TTL is enforced by the `expiresAt` column.
const RESET_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes

// Email verification tokens (SEC-001, migration 003).
// 24-hour TTL gives users plenty of time to check their inbox.
const VERIFICATION_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours

// Rate limiters — separate buckets per endpoint category so flooding
// one endpoint (e.g. forgot-password) doesn't lock out login.
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes

// CI/test environments (SKIP_EMAIL_VERIFICATION=true) run dozens of
// register+login cycles from 127.0.0.1 in seconds. The production budget
// of 10 logins / 15 min would cause cascading 429s in the test suite.
// Raise the ceiling to 200 in dev/CI mode — the bucket is still enforced
// so the rate-limit code path is exercised, just with a higher threshold.
//
// Evaluated lazily at each rate-limit check (NOT at module-load time) so
// tests that call `setupEnv({ SKIP_EMAIL_VERIFICATION: "true" })` AFTER
// importing this module still pick up the raised ceiling.
function _isTestMode() {
  return process.env.SKIP_EMAIL_VERIFICATION === "true"
    || process.env.RATE_LIMIT_TEST_MODE === "true";
}
const rateBuckets = {
  login:         { map: new Map(), max: 10, testMax: 200 },  // 10 login attempts per IP per 15 min (200 in test mode)
  forgotPassword:{ map: new Map(), max: 5 },   // 5 reset requests per IP per 15 min
  resetPassword: { map: new Map(), max: 5 },   // 5 reset attempts per IP per 15 min
  // SEC-004: MFA-specific buckets — verify is the brute-force surface (only
  // 6 digits of entropy per attempt), enroll prevents secret-flooding abuse.
  // testMax raises the ceiling in CI so the MFA test suite (which runs
  // ~15 setupUser() cycles per file from 127.0.0.1) doesn't trip 429.
  mfaVerify:        { map: new Map(), max: 5,  testMax: 200 },   // 5 TOTP verify attempts per IP per 15 min
  mfaEnroll:        { map: new Map(), max: 3,  testMax: 200 },   // 3 enroll cycles per IP per 15 min
  // SEC-004 §5a: WebAuthn verify is also a brute-force surface (an attacker
  // with a stolen pendingToken could try arbitrary assertions). Same budget
  // as TOTP — failed assertions cost the same as failed codes.
  webauthnVerify:   { map: new Map(), max: 5,  testMax: 200 },   // 5 passkey verify attempts per IP per 15 min
};

/**
 * Check rate limit for a specific bucket.
 * @param {string} bucket — key in rateBuckets (e.g. "login", "forgotPassword")
 * @param {string} ip
 * @returns {{ allowed: boolean, retryAfterSec: number }}
 */
function checkRateLimit(bucket, ip) {
  const cfg = rateBuckets[bucket];
  // Honour `testMax` in dev/CI mode (SKIP_EMAIL_VERIFICATION=true). Resolved
  // here, not at module-load, so tests setting the env after import still
  // pick up the raised ceiling.
  const max = (cfg.testMax !== undefined && _isTestMode()) ? cfg.testMax : cfg.max;
  const { map } = cfg;
  const now = Date.now();
  const entry = map.get(ip);
  if (!entry || entry.resetAt < now) {
    map.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
    return { allowed: true };
  }
  if (entry.count >= max) {
    const retryAfterSec = Math.ceil((entry.resetAt - now) / 1000);
    return { allowed: false, retryAfterSec };
  }
  entry.count++;
  return { allowed: true };
}

// Purge expired DB tokens periodically (reset + verification).
// In-memory revoked JWT purging is handled by middleware/authenticate.js.
// .unref() prevents this timer from keeping the process alive during tests.
const _purgeInterval = setInterval(() => {
  try {
    resetTokenRepo.deleteExpired();
  } catch (err) { console.error(formatLogLine("error", null, `[auth/purge] Failed to delete expired reset tokens: ${err.message}`)); }
  try {
    verificationTokenRepo.deleteExpired();
  } catch (err) { console.error(formatLogLine("error", null, `[auth/purge] Failed to delete expired verification tokens: ${err.message}`)); }
}, 60 * 60 * 1000);
_purgeInterval.unref();

// requireAuth is exported above as an alias for requireUser from
// middleware/authenticate.js — see the import block at the top of this file.

// ─── Validation helpers ───────────────────────────────────────────────────────

/**
 * Validate an email address format.
 * @param   {string}  email - The email to validate.
 * @returns {boolean}         `true` if the format is valid.
 * @private
 */
function isValidEmail(email) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email);
}
/**
 * Trim and truncate a string to prevent oversized input.
 * Returns empty string for non-string values.
 *
 * @param   {*}      str           - The value to sanitise.
 * @param   {number} [maxLen=200]  - Maximum allowed length.
 * @returns {string}                 The sanitised string.
 * @private
 */
function sanitiseString(str, maxLen = 200) {
  return typeof str === "string" ? str.trim().slice(0, maxLen) : "";
}

// ─── Password strength validation (GAP-02) ───────────────────────────────────
// Enforces complexity beyond a minimum length: at least one uppercase letter,
// one lowercase letter, one digit, and one special character.  Also rejects the
// 20 most common passwords that pass the character-class checks.

const COMMON_PASSWORDS = new Set([
  "password", "12345678", "123456789", "1234567890", "qwerty123",
  "password1", "iloveyou", "sunshine1", "princess1", "football1",
  "charlie1", "access14", "trustno1", "passw0rd", "master123",
  "welcome1", "monkey123", "dragon12", "letmein1", "abc12345",
]);

/**
 * Validate password strength.
 * @param   {string} password
 * @returns {string|null} Error message, or null if valid.
 */
function validatePasswordStrength(password) {
  if (typeof password !== "string" || password.length < 8) {
    return "Password must be at least 8 characters.";
  }
  if (password.length > 128) {
    return "Password is too long.";
  }
  if (!/[A-Z]/.test(password)) {
    return "Password must contain at least one uppercase letter.";
  }
  if (!/[a-z]/.test(password)) {
    return "Password must contain at least one lowercase letter.";
  }
  if (!/[0-9]/.test(password)) {
    return "Password must contain at least one digit.";
  }
  if (!/[^A-Za-z0-9]/.test(password)) {
    return "Password must contain at least one special character.";
  }
  if (COMMON_PASSWORDS.has(password.toLowerCase())) {
    return "This password is too common. Please choose a stronger one.";
  }
  return null;
}

/**
 * Ensure the user belongs to at least one workspace, creating a personal
 * workspace if needed.  Called from every auth path (login, OAuth) so no
 * user can end up without a workspace.
 *
 * @param {Object} user — User row from the database.
 */
function ensureUserWorkspace(user) {
  const existing = workspaceRepo.getByUserId(user.id);
  if (existing && existing.length > 0) return;
  const slug = `${(user.name || "user").toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${crypto.randomBytes(3).toString("hex")}`;
  workspaceRepo.create({ name: `${user.name || "My"}'s Workspace`, slug, ownerId: user.id });
}


// ─── SEC-004: TOTP / MFA helpers ─────────────────────────────────────────────
//
// In-memory pending-login store. Keyed by an opaque base64url token returned
// to the client when password verification succeeds but MFA is still required.
// Consumed by POST /mfa/verify in exchange for the auth cookie.
//
// Tokens are single-use and expire after MFA_LOGIN_TTL_MS. A periodic sweep
// removes expired entries so an abandoned MFA flow cannot accumulate memory
// indefinitely. For multi-replica deployments this Map should move to Redis;
// tracked under SEC-004c as a follow-up.
const mfaPendingLogins = new Map();
const MFA_LOGIN_TTL_MS = parseInt(process.env.MFA_PENDING_TTL_MS ?? "", 10) || 5 * 60 * 1000;

// SEC-004 §5d: per-pendingToken attempt counter. The IP-based `mfaVerify`
// limiter (5/15min) is necessary but not sufficient — an attacker rotating
// source IPs has the full 5-minute MFA_LOGIN_TTL_MS window to brute-force
// the 6-digit TOTP space (1M codes ≫ achievable on a botnet). Bind the
// budget to the pendingToken so the entire MFA session is consumed after
// `MFA_MAX_ATTEMPTS` failures regardless of IP — the attacker has to
// re-prove the password to get a fresh token. Configurable in case a
// deployment wants a tighter or looser ceiling; default 5 matches the
// per-IP rate limiter.
const MFA_MAX_ATTEMPTS = Math.max(1, parseInt(process.env.MFA_MAX_ATTEMPTS ?? "", 10) || 5);

const _mfaPurgeInterval = setInterval(() => {
  const now = Date.now();
  for (const [k, v] of mfaPendingLogins) {
    if (v.expiresAt < now) mfaPendingLogins.delete(k);
  }
}, 5 * 60 * 1000);
_mfaPurgeInterval.unref();

/**
 * Decode a base32-encoded TOTP secret to its raw byte buffer (RFC 4648).
 * Tolerant of whitespace, lowercase, and trailing `=` padding.
 * @param {string} input
 * @returns {Buffer}
 * @private
 */
function base32Decode(input) {
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  const clean = String(input || "").toUpperCase().replace(/=+$/g, "").replace(/[^A-Z2-7]/g, "");
  let bits = "";
  for (const c of clean) {
    const v = alphabet.indexOf(c);
    if (v < 0) continue;
    bits += v.toString(2).padStart(5, "0");
  }
  const out = [];
  for (let i = 0; i + 8 <= bits.length; i += 8) out.push(parseInt(bits.slice(i, i + 8), 2));
  return Buffer.from(out);
}
/**
 * Generate a fresh 160-bit (32-char base32) TOTP secret. Matches RFC 6238 /
 * Google Authenticator defaults so any standard authenticator app interops.
 * @returns {string}
 * @private
 */
function generateTotpSecret() {
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  const bytes = crypto.randomBytes(20);
  let out = "";
  let bits = 0; let value = 0;
  for (const b of bytes) {
    value = (value << 8) | b;
    bits += 8;
    while (bits >= 5) { out += alphabet[(value >>> (bits - 5)) & 31]; bits -= 5; }
  }
  if (bits > 0) out += alphabet[(value << (5 - bits)) & 31];
  return out;
}

/**
 * Compute the RFC 6238 TOTP code for a given base32 secret at a given step
 * counter. Single source of truth for both the production `verifyTotp` loop
 * and the test helper `generateTotpCode` — so any algorithm change
 * (SHA-256, different digit count, different period) breaks tests
 * immediately rather than silently letting production drift.
 *
 * @param {string} secret      - Base32 TOTP secret.
 * @param {number} stepCounter - 30-second step counter (`floor(unixSeconds / 30)`).
 * @returns {string} Zero-padded 6-digit code.
 * @private
 */
function computeTotpAtStep(secret, stepCounter) {
  const key = base32Decode(secret);
  const counter = Buffer.alloc(8);
  counter.writeBigUInt64BE(BigInt(stepCounter));
  const hmac = crypto.createHmac("sha1", key).update(counter).digest();
  const off = hmac[hmac.length - 1] & 0xf;
  const code = ((hmac[off] & 0x7f) << 24) | ((hmac[off + 1] & 0xff) << 16) | ((hmac[off + 2] & 0xff) << 8) | (hmac[off + 3] & 0xff);
  return String(code % 1_000_000).padStart(6, "0");
}

/**
 * Internal cross-module helper — exposes the TOTP code generator so the test
 * suite (`backend/tests/helpers/test-base.js`) can produce valid codes
 * without re-implementing base32-decode + HMAC. Keeping this in production
 * code means any algorithm drift (SHA-256, 8-digit, etc.) is reflected in
 * tests on the same commit. Underscore prefix marks it as not part of the
 * public API contract.
 *
 * @param {string} secret           - Base32 TOTP secret.
 * @param {number} [offsetSteps=0]  - Step offset from `now` (clock-skew tests).
 * @returns {string} 6-digit code.
 */
export function _internalGenerateTotpCode(secret, offsetSteps = 0) {
  const step = 30;
  const now = Math.floor(Date.now() / 1000 / step);
  return computeTotpAtStep(secret, now + offsetSteps);
}

/**
 * Verify a 6-digit TOTP code against a base32 secret. Allows ±`window` steps
 * (default 30s each) of clock skew either side of `now`. Configurable via the
 * `MFA_TOTP_WINDOW` env var (default 1 = ±30s tolerance).
 *
 * Constant-time: iterates every candidate window even after a match and uses
 * `crypto.timingSafeEqual` for the digit comparison so total runtime does not
 * leak which window (or whether any) matched.
 *
 * @param {string} token  - User-supplied 6-digit code.
 * @param {string} secret - Base32 TOTP secret.
 * @param {number} [window]
 * @returns {boolean}
 * @private
 */
function verifyTotp(token, secret, window) {
  const w = Number.isFinite(window) ? window : (parseInt(process.env.MFA_TOTP_WINDOW ?? "1", 10) || 1);
  const t = String(token || "").replace(/\s+/g, "");
  if (!/^\d{6}$/.test(t)) return false;
  const step = 30;
  const now = Math.floor(Date.now() / 1000 / step);
  const tBuf = Buffer.from(t);
  let matched = false;
  for (let i = -w; i <= w; i++) {
    const computed = computeTotpAtStep(secret, now + i);
    try {
      if (crypto.timingSafeEqual(Buffer.from(computed), tBuf)) matched = true;
    } catch { /* length mismatch — t validated as /^\d{6}$/ above so unreachable */ }
  }
  return matched;
}

/**
 * SHA-256 hash a recovery code for storage. Recovery codes are user-facing
 * secrets — never persist the raw form.
 * @param {string} code
 * @returns {string} 64-char hex digest.
 * @private
 */
function hashRecoveryCode(code) {
  return crypto.createHash("sha256").update(String(code)).digest("hex");
}

/**
 * SEC-004 §5b: maximum recovery-code list length used as the constant-time
 * iteration ceiling. Must be ≥ the largest `MFA_RECOVERY_CODES_COUNT` any
 * deployment could configure. Larger than the default (8) and any
 * realistic admin-tuned value, so the scan iterates the same number of
 * slots regardless of how many codes a particular user has remaining —
 * preventing timing-based remaining-count exfiltration across users.
 * @private
 */
const RECOVERY_CODE_SCAN_CEILING = 32;

/**
 * Constant-time scan for a hashed recovery code in a list (SEC-004 §5b).
 * `indexOf` short-circuits on first match and leaks via timing which slot
 * matched. This iterates every entry regardless of outcome and only records
 * the first match.
 *
 * The loop iterates `RECOVERY_CODE_SCAN_CEILING` times — NOT `codes.length`
 * — because the real list length is itself sensitive: a user with 1 code
 * remaining produces a measurably faster scan than a user with 8, leaking
 * recovery-code consumption across accounts. We pad with dummy hashes
 * (zero-buffer compares) so total runtime is independent of `codes.length`.
 *
 * @param {string[]} codes  - Pre-hashed recovery codes (hex strings).
 * @param {string}   target - Pre-hashed candidate (hex string).
 * @returns {number} Index of the match, or -1.
 * @private
 */
function findRecoveryCodeIndex(codes, target) {
  let matchIdx = -1;
  const targetBuf = Buffer.from(target);
  // Dummy buffer for padding iterations — same length as target so
  // timingSafeEqual does not throw and the dummy compare runs in the same
  // time as a real compare. The dummy is all-zero ASCII; since target is a
  // SHA-256 hex digest (lowercase hex characters), the timingSafeEqual will
  // never spuriously match.
  const dummyBuf = Buffer.alloc(target.length, 0x30 /* '0' */);
  for (let i = 0; i < RECOVERY_CODE_SCAN_CEILING; i++) {
    const code = i < codes.length ? codes[i] : null;
    const candidate = (typeof code === "string" && code.length === target.length)
      ? Buffer.from(code)
      : dummyBuf;
    try {
      if (crypto.timingSafeEqual(candidate, targetBuf) && matchIdx < 0 && candidate !== dummyBuf) {
        matchIdx = i;
      }
    } catch { /* length mismatch filtered above — unreachable */ }
  }
  return matchIdx;
}

/**
 * Mint a fresh set of MFA recovery codes. Returns both the raw codes (shown
 * to the user once) and their SHA-256 hashes (persisted to the DB).
 * Count is configurable via `MFA_RECOVERY_CODES_COUNT` (default 8).
 * @returns {{ raw: string[], hashed: string[] }}
 * @private
 */
function mintRecoveryCodes() {
  const count = Math.max(1, parseInt(process.env.MFA_RECOVERY_CODES_COUNT ?? "8", 10) || 8);
  const raw = Array.from({ length: count }, () => crypto.randomBytes(4).toString("hex"));
  return { raw, hashed: raw.map(hashRecoveryCode) };
}

/**
 * Issue a single-use pending-MFA token after password verification succeeds.
 * Exchanged at `POST /mfa/verify` for the auth cookie.
 *
 * The `workspaceId` is snapshotted here so subsequent `auth.mfa.*` audit log
 * entries can attribute the event to a workspace — without it, the rows are
 * persisted with `workspaceId = NULL` and disappear from the workspace-scoped
 * activity view that admins rely on for security monitoring.
 *
 * @param {string} userId
 * @param {string} [workspaceId] - Resolved active workspace at login time.
 * @returns {string} Opaque base64url token (24 bytes of entropy).
 * @private
 */
function createPendingMfaLogin(userId, workspaceId) {
  const token = crypto.randomBytes(24).toString("base64url");
  mfaPendingLogins.set(token, { userId, workspaceId, attempts: 0, expiresAt: Date.now() + MFA_LOGIN_TTL_MS });
  return token;
}

/**
 * SEC-004 §5d: increment the per-pendingToken failure count. When the count
 * reaches `MFA_MAX_ATTEMPTS` the token is consumed (deleted) so the attacker
 * cannot continue submitting codes against it from a new IP. Caller decides
 * how to respond — typically a 401 indistinguishable from "session expired"
 * so the attacker cannot tell whether they exhausted attempts or simply hit
 * an expired token.
 *
 * Exported for use by the WebAuthn flow which shares the same pendingToken
 * and faces an identical brute-force window on assertion failures.
 *
 * @param {string} token
 * @returns {{ exhausted: boolean, remaining: number } | null} `null` when the
 *   token is missing/expired (caller treats as session-expired). Otherwise
 *   `exhausted: true` when this attempt tipped the budget over the limit
 *   (token has already been deleted), `false` when more attempts remain.
 * @private
 */
function recordPendingMfaFailure(token) {
  const entry = mfaPendingLogins.get(token);
  if (!entry) return null;
  if (entry.expiresAt < Date.now()) {
    mfaPendingLogins.delete(token);
    return null;
  }
  entry.attempts = (entry.attempts || 0) + 1;
  if (entry.attempts >= MFA_MAX_ATTEMPTS) {
    mfaPendingLogins.delete(token);
    return { exhausted: true, remaining: 0 };
  }
  return { exhausted: false, remaining: MFA_MAX_ATTEMPTS - entry.attempts };
}

/**
 * Internal cross-module helper — `routes/webauthn.js` shares the
 * pendingToken brute-force surface (a stolen token can be replayed against
 * arbitrary assertion attempts) and must apply the same per-token strike
 * budget. Underscore prefix marks it as not part of the public API contract.
 * @param {string} token
 * @returns {{ exhausted: boolean, remaining: number } | null}
 */
export function _internalRecordPendingMfaFailure(token) {
  return recordPendingMfaFailure(token);
}

/**
 * Atomically consume a pending-MFA token. Returns the entry on success and
 * deletes it (single-use). Returns null when missing, already consumed, or
 * expired.
 *
 * The optional `{ peek: true }` option returns the entry WITHOUT consuming
 * it — used by the WebAuthn flow's `/authenticate/options` endpoint, which
 * needs to look up the user before issuing a challenge but must leave the
 * token intact for the subsequent `/authenticate/verify` call.
 *
 * @param {string} token
 * @param {Object} [opts]
 * @param {boolean} [opts.peek]
 * @returns {{ userId: string, expiresAt: number } | null}
 * @private
 */
function consumePendingMfaLogin(token, opts) {
  const entry = mfaPendingLogins.get(token);
  if (!entry) return null;
  if (entry.expiresAt < Date.now()) {
    mfaPendingLogins.delete(token);
    return null;
  }
  if (!opts?.peek) mfaPendingLogins.delete(token);
  return entry;
}

/**
 * Internal cross-module helper — exported so `routes/webauthn.js` can
 * consume / peek pending-MFA tokens issued by `/login` without duplicating
 * the in-memory store. Underscore prefix marks it as not part of the
 * public API contract.
 * @param {string} token
 * @param {Object} [opts]
 * @param {boolean} [opts.peek]
 * @returns {{ userId: string, expiresAt: number } | null}
 */
export function _internalConsumePendingMfaLogin(token, opts) {
  return consumePendingMfaLogin(token, opts);
}

/**
 * Internal cross-module helper — exported so `routes/webauthn.js` can reuse
 * the password-confirmation policy (OAuth-only users skip the password
 * check; everyone else must supply a matching password).
 * @param {Object} user
 * @param {string} password
 * @returns {Promise<boolean>}
 */
export async function _internalVerifyAccountPassword(user, password) {
  return verifyAccountPassword(user, password);
}

/**
 * Internal cross-module helper — exposes the per-bucket rate limiter so the
 * WebAuthn router can apply the shared `webauthnVerify` bucket without
 * duplicating the limiter state.
 * @param {string} bucket
 * @param {string} ip
 * @returns {{ allowed: boolean, retryAfterSec: number }}
 */
export function _internalCheckRateLimit(bucket, ip) {
  return checkRateLimit(bucket, ip);
}

/**
 * SEC-004: Industry-standard "security-posture change → terminate session"
 * primitive. Revokes the current request's JTI and clears the auth cookies
 * so the caller is forced to re-authenticate immediately. Matches the
 * DELETE /account pattern and the behaviour of Auth0, Clerk, Okta, GitHub
 * on credential / MFA changes.
 *
 * Exported as an underscore-prefixed cross-module helper so
 * `routes/webauthn.js` can apply the same semantics on passkey removal
 * without duplicating the JTI-revoke + cookie-clear plumbing.
 *
 * @param {Object} req - Must carry `req.authUser` (the JWT payload).
 * @param {Object} res
 */
export function _internalRevokeCurrentSession(req, res, auditMeta) {
  const { jti, exp } = req.authUser || {};
  if (jti) revokedTokens.set(jti, exp);
  clearAuthCookies(res);
  // SEC-007: emit `auth.session.revoke` so every server-initiated session
  // termination (MFA disable, recovery-code regeneration, passkey removal,
  // etc.) lands in the compliance audit log alongside login/logout events.
  // The `reason` in `auditMeta` lets reviewers distinguish posture-change
  // revokes from explicit logouts.
  if (req?.authUser?.sub) {
    logActivity({
      type: "auth.session.revoke",
      req,
      userId: req.authUser.sub,
      userName: req.authUser.name || req.authUser.email || null,
      workspaceId: req.workspaceId || req.authUser.workspaceId || null,
      meta: { jti: jti || null, ...(auditMeta || {}) },
    });
  }
}

/**
 * SEC-004: apply workspace MFA enforcement at the end of an auth flow.
 *
 * Centralises the three-way `evaluateMfaEnforcement(user)` outcome handling
 * that was previously duplicated across `/login`, `/github/callback`, and
 * `/google/callback`:
 *   - `block` → emit `auth.mfa.enrollment_required` activity, respond 403
 *     with `code: "MFA_ENROLLMENT_REQUIRED"`, return `true` (caller must stop)
 *   - `grace` → set `X-MFA-Grace-Period-Days-Remaining` + `X-MFA-Grace-Ends-At`
 *     headers, return `false` (caller proceeds with cookie issue)
 *   - `allow` → no-op, return `false`
 *
 * @param {Object} req  - SEC-007: forwarded to `logActivity` so the
 *   `auth.mfa.enrollment_required` row captures `ipAddress` + `userAgent`.
 *   Without it the row lands with both columns NULL, breaking SOC-2
 *   session-reconstruction evidence.
 * @param {Object} res
 * @param {Object} user
 * @param {Object} auditMeta - Forwarded into the activity `meta` field
 *   (e.g. `{ method: "password" }`, `{ method: "oauth", provider: "github" }`).
 * @param {string} auditDetail - Human-readable detail for the activity row.
 * @returns {boolean} `true` when the request was already terminated with 403.
 * @private
 */
function applyMfaEnforcement(req, res, user, auditMeta, auditDetail) {
  const enforcement = evaluateMfaEnforcement(user);
  if (enforcement.state === "block") {
    logActivity({
      type: "auth.mfa.enrollment_required",
      req,
      detail: auditDetail,
      userId: user.id, userName: user.name || user.email,
      workspaceId: enforcement.workspaceId,
      meta: auditMeta,
    });
    res.status(403).json({
      error: "Your workspace requires multi-factor authentication. Enroll before signing in.",
      code: "MFA_ENROLLMENT_REQUIRED",
      workspaceId: enforcement.workspaceId,
      workspaceName: enforcement.workspaceName,
    });
    return true;
  }
  if (enforcement.state === "grace") {
    res.setHeader("X-MFA-Grace-Period-Days-Remaining", String(enforcement.gracePeriodDaysRemaining));
    res.setHeader("X-MFA-Grace-Ends-At", enforcement.graceEndsAt);
  }
  return false;
}
// ─── Routes ──────────────────────────────────────────────────────────────────

/**
 * Register a new user with email and password.
 *
 * @route POST /api/v1/auth/register
 * @param {Object} req.body
 * @param {string} req.body.name     - Full name (max 100 chars).
 * @param {string} req.body.email    - Email address (max 254 chars).
 * @param {string} req.body.password - Password (8–128 chars).
 * @returns {201} `{ message }` on success.
 * @returns {400} Validation error.
 * @returns {409} Email already exists.
 */
router.post("/register", async (req, res) => {
  try {
    const name     = sanitiseString(req.body.name, 100);
    const email    = sanitiseString(req.body.email, 254).toLowerCase();
    const password = req.body.password;

    if (!name)                       return res.status(400).json({ error: "Name is required." });
    if (!isValidEmail(email))        return res.status(400).json({ error: "A valid email address is required." });
    const pwErr = validatePasswordStrength(password);
    if (pwErr)                       return res.status(400).json({ error: pwErr });

    const existing = userRepo.getByEmail(email);
    if (existing) {
      return res.status(409).json({ error: "An account with this email already exists." });
    }

    const id           = crypto.randomUUID();
    const passwordHash = await hashPassword(password);
    const now          = new Date().toISOString();

    // SEC-001: New users are created with emailVerified = 0 (false).
    // They must verify their email before they can log in.
    // SKIP_EMAIL_VERIFICATION=true bypasses this for dev/CI environments.
    const skipVerification = process.env.SKIP_EMAIL_VERIFICATION === "true";

    // Set emailVerified atomically at creation time — no separate UPDATE needed.
    // This prevents a window where the user exists with emailVerified=1 if the
    // update were to fail after the INSERT.
    const user = { id, name, email, passwordHash, role: "user", emailVerified: skipVerification ? 1 : 0, createdAt: now, updatedAt: now };
    userRepo.create(user);

    if (skipVerification) {
      // Auto-verify: dev/CI mode — no email required
      return res.status(201).json({ message: "Account created successfully." });
    }

    // Generate and send verification email
    const verifyToken = crypto.randomBytes(32).toString("base64url");
    const tokenExpiresAt = new Date(Date.now() + VERIFICATION_TOKEN_TTL_MS).toISOString();
    try {
      verificationTokenRepo.create(verifyToken, id, email, tokenExpiresAt);
      await sendVerificationEmail(email, verifyToken, name);
    } catch (emailErr) {
      // Non-fatal: account is created, user can request a resend.
      console.error(formatLogLine("error", null, `[auth/register] Failed to send verification email: ${emailErr.message}`));
    }

    return res.status(201).json({
      message: "Account created. Please check your email to verify your account.",
      requiresVerification: true,
    });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/register] ${err.message}`));
    return res.status(500).json({ error: "Registration failed. Please try again." });
  }
});

/**
 * Sign in with email and password. Sets auth cookies and returns user profile.
 * Rate-limited to 10 attempts per IP per 15 minutes.
 *
 * @route POST /api/v1/auth/login
 * @param {Object} req.body
 * @param {string} req.body.email    - Email address.
 * @param {string} req.body.password - Password.
 * @returns {200} `{ user: { id, name, email, role, avatar, workspaceId, workspaceName, workspaceRole } }`.
 * @returns {400} Invalid input.
 * @returns {401} Wrong credentials.
 * @returns {403} Email not verified.
 * @returns {429} Rate limit exceeded (`Retry-After` header set).
 */
router.post("/login", async (req, res) => {
  const ip = req.ip || "unknown";
  const rate = checkRateLimit("login", ip);
  if (!rate.allowed) {
    res.setHeader("Retry-After", rate.retryAfterSec);
    return res.status(429).json({ error: `Too many sign-in attempts. Try again in ${Math.ceil(rate.retryAfterSec / 60)} minutes.` });
  }

  try {
    const email    = sanitiseString(req.body.email, 254).toLowerCase();
    const password = req.body.password;

    if (!isValidEmail(email) || typeof password !== "string") {
      return res.status(400).json({ error: "Invalid email or password." });
    }

    const user = userRepo.getByEmail(email);

    // Always run verifyPassword (even on non-existent user) to prevent timing attacks
    const dummyHash = "00000000000000000000000000000000:0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
    const valid = user?.passwordHash ? await verifyPassword(password, user.passwordHash) : await verifyPassword(password, dummyHash).catch(() => false);

    if (!user || !valid) {
      // SEC-007: route the failed-login row so it's reachable from an admin
      // UI, never `workspaceId: null` (which is invisible to every scoped
      // query). Three cases:
      //   1. Known email → snapshot the user's primary workspace; the row
      //      appears in that workspace's audit log.
      //   2. Unknown email (credential-stuffing probe) → tag with the
      //      `SYSTEM_WORKSPACE_ID` sentinel so admins can list it via
      //      `GET /api/v1/system/security-events`. The sentinel is a fixed
      //      string that can never collide with a real workspace UUID, so
      //      the row cannot leak into any tenant's workspace-scoped view.
      //   3. Empty-string email → treat as case 2 for safety.
      const failedWorkspaceId = user
        ? workspaceRepo.getByUserId(user.id)?.[0]?.id || SYSTEM_WORKSPACE_ID
        : SYSTEM_WORKSPACE_ID;
      logActivity({ type: "auth.login.failed", req, userId: user?.id || null, userName: user?.name || email || null, workspaceId: failedWorkspaceId });
      return res.status(401).json({ error: "Invalid email or password." });
    }

    // SEC-001: Block login for unverified email accounts.
    // emailVerified defaults to 1 for existing/OAuth users (migration 003),
    // so only new email/password registrations are affected.
    if (user.emailVerified === 0) {
      return res.status(403).json({
        error: "Please verify your email address before signing in.",
        code: "EMAIL_NOT_VERIFIED",
        email: user.email,
      });
    }

    // ACL-001: Ensure the user has a workspace before issuing a token.
    ensureUserWorkspace(user);

    // SEC-004: MFA challenge — issue a pendingToken if EITHER a TOTP secret
    // is configured OR the user has registered WebAuthn credentials. The
    // frontend uses the `methods` map to render a factor picker.
    const hasTotp = user.mfaEnabled === 1 && !!user.mfaSecret;
    const webauthnCount = webauthnRepo.countByUser(user.id);
    if (hasTotp || webauthnCount > 0) {
      // Snapshot the active workspace into the pending entry so /mfa/verify
      // can attribute audit-log rows. ensureUserWorkspace() above guarantees
      // at least one membership exists.
      const memberships = workspaceRepo.getByUserId(user.id);
      const activeWorkspaceId = memberships?.[0]?.id;
      const pendingToken = createPendingMfaLogin(user.id, activeWorkspaceId);
      return res.status(200).json({
        mfaRequired: true,
        pendingToken,
        methods: { totp: hasTotp, webauthn: webauthnCount > 0 },
      });
    }

    // SEC-004: per-workspace MFA enforcement (block past grace, banner within).
    if (applyMfaEnforcement(req, res, user, { method: "password" }, "Login blocked: workspace requires MFA.")) return;

    // SEC-004 §5c: tag password-only sessions with amr=["pwd"] so future
    // step-up auth checks can require MFA-asserted sessions explicitly.
    const payload = buildJwtPayload(user, undefined, { amr: ["pwd"] });
    const token = signJwt(payload, getJwtSecret());
    const exp   = Math.floor(Date.now() / 1000) + JWT_TTL_SEC;

    setAuthCookie(res, token, exp);
    logActivity({ type: "auth.login", req, userId: user.id, userName: user.name || user.email, workspaceId: payload.workspaceId });

    return res.json({ user: buildUserResponse(user) });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/login] ${err.message}`));
    return res.status(500).json({ error: "Sign-in failed. Please try again." });
  }
});

/**
 * Sign out — revokes the JWT server-side so it can't be reused.
 * Requires `Authorization: Bearer <token>`.
 *
 * @route POST /api/v1/auth/logout
 * @returns {200} `{ message: "Signed out successfully." }`.
 * @returns {401} Missing or invalid token.
 */
router.post("/logout", requireAuth, (req, res) => {
  const { jti, exp } = req.authUser;
  if (jti) revokedTokens.set(jti, exp);
  logActivity({ type: "auth.logout", req, userId: req.authUser.sub, userName: req.authUser.name || null, workspaceId: req.workspaceId || req.authUser.workspaceId || null });
  clearAuthCookies(res);
  return res.json({ message: "Signed out successfully." });
});

/**
 * Get the currently authenticated user's profile with workspace context.
 *
 * @route GET /api/v1/auth/me
 * @returns {200} `{ id, name, email, role, avatar, createdAt, workspaceId, workspaceName, workspaceRole }`.
 * @returns {401} Missing or invalid token.
 * @returns {404} User not found in database.
 */
router.get("/me", requireAuth, (req, res) => {
  const user = userRepo.getById(req.authUser.sub);
  if (!user) return res.status(404).json({ error: "User not found." });
  return res.json({ ...buildUserResponse(user, req.authUser.workspaceId), createdAt: user.createdAt });
});

// ─── Account export / deletion (SEC-003) ─────────────────────────────────────

/**
 * Check whether a user registered via OAuth only (no password set).
 *
 * @param {Object} user
 * @returns {boolean}
 */
function isOAuthOnlyUser(user) {
  return !user?.passwordHash;
}

/**
 * Verify the authenticated user's password for sensitive account actions.
 * For OAuth-only users (no passwordHash), password verification is skipped
 * — the OAuth session itself serves as proof of identity.
 *
 * @param {Object} user
 * @param {string} password
 * @returns {Promise<boolean>}
 */
async function verifyAccountPassword(user, password) {
  if (isOAuthOnlyUser(user)) return true;
  if (typeof password !== "string" || !password) return false;
  return verifyPassword(password, user.passwordHash);
}

/**
 * Export all user-owned account data as JSON (GDPR/CCPA data portability).
 * Requires password confirmation via `x-account-password` header.
 *
 * @route GET /api/v1/auth/export
 * @returns {200} JSON export payload.
 * @returns {400} Missing password.
 * @returns {403} Invalid password.
 */
router.get("/export", requireAuth, async (req, res) => {
  const user = userRepo.getById(req.authUser.sub);
  if (!user) return res.status(404).json({ error: "User not found." });

  // OAuth-only users have no password — skip confirmation (session is proof).
  if (!isOAuthOnlyUser(user)) {
    const password = req.headers["x-account-password"];
    if (typeof password !== "string" || !password) {
      return res.status(400).json({ error: "Password confirmation is required." });
    }
    const valid = await verifyAccountPassword(user, password);
    if (!valid) {
      return res.status(403).json({ error: "Password confirmation failed." });
    }
  }

  const data = accountRepo.buildAccountExport(user.id);
  return res.json(data);
});

/**
 * Delete account and all user-owned workspace data (GDPR right to erasure).
 * Requires password confirmation.
 *
 * @route DELETE /api/v1/auth/account
 * @param {Object} req.body
 * @param {string} req.body.password - Account password confirmation.
 * @returns {200} `{ ok: true }` on success.
 */
router.delete("/account", requireAuth, async (req, res) => {
  const user = userRepo.getById(req.authUser.sub);
  if (!user) return res.status(404).json({ error: "User not found." });

  // OAuth-only users have no password — skip confirmation (session is proof).
  if (!isOAuthOnlyUser(user)) {
    const password = req.body?.password;
    if (typeof password !== "string" || !password) {
      return res.status(400).json({ error: "Password confirmation is required." });
    }
    const valid = await verifyAccountPassword(user, password);
    if (!valid) {
      return res.status(403).json({ error: "Password confirmation failed." });
    }
  }

  // Collect owned project IDs before deletion so we can stop their in-memory
  // cron tasks afterwards. deleteAccount() removes the DB rows but cannot
  // reach the process-local scheduler task Map.
  const ownedWsIds = workspaceRepo.getOwnedWorkspaceIds(user.id);
  const ownedProjectIds = ownedWsIds.flatMap(wsId => projectRepo.getAllIncludeDeleted(wsId).map(p => p.id));

  try {
    accountRepo.deleteAccount(user.id);
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/account] Delete failed for user ${user.id}: ${err.message}`));
    return res.status(500).json({ error: "Failed to delete account." });
  }

  // Stop in-memory cron tasks for deleted projects (no-op if no schedule existed).
  for (const projectId of ownedProjectIds) {
    stopSchedule(projectId);
  }

  // Revoke the current JWT so it cannot be reused from another tab or replay.
  const { jti, exp } = req.authUser;
  if (jti) revokedTokens.set(jti, exp);
  clearAuthCookies(res);
  return res.json({ ok: true, message: "Account and owned data deleted." });
});

/**
 * Refresh the session — issue a new JWT and reset the cookie TTL.
 * Called proactively by the frontend 5 minutes before expiry so users
 * never get silently logged out mid-session.
 *
 * The existing token must still be valid (not expired, not revoked).
 * The old JTI is revoked and a new one is issued.
 *
 * @route POST /api/v1/auth/refresh
 * @returns {200} `{ user }` — same shape as login response.
 * @returns {401} If the current session is invalid or expired.
 */
router.post("/refresh", requireAuth, (req, res) => {
  const user = userRepo.getById(req.authUser.sub);
  if (!user) return res.status(401).json({ error: "User not found." });

  // SEC-004 §7: re-check workspace MFA enforcement on every refresh so a
  // policy change mid-session (admin enables mfaRequired with grace=0) is
  // enforced within one refresh cycle (~8h) rather than never. Without this,
  // a user whose session pre-dates the policy change stays logged in
  // indefinitely via the automatic refresh loop.
  //
  // ORDERING: enforcement check MUST run BEFORE revoking the old token —
  // otherwise a 403 `MFA_ENROLLMENT_REQUIRED` response leaves a revoked JTI
  // in the cookie jar, and every subsequent authenticated request fails with
  // a generic 401 "Session expired" instead of the actionable 403 the
  // frontend uses to render the MFA enrollment panel. Mirrors the ordering
  // in `/workspaces/switch` at routes/workspaces.js:152-170.
  if (applyMfaEnforcement(req, res, user, { method: "refresh" }, "Session refresh blocked: workspace requires MFA.")) return;

  // Revoke the old token only after enforcement passes
  const { jti: oldJti, exp: oldExp } = req.authUser;
  if (oldJti) revokedTokens.set(oldJti, oldExp);

  // Issue a fresh token with a new JTI (includes updated workspace context).
  // SEC-004 §5c: forward the existing `amr` claim so an MFA-asserted session
  // (`["pwd","mfa"]`) stays MFA-asserted after the 8-hour refresh cycle.
  // Without this, every refresh would silently downgrade the session to
  // password-only, breaking any future step-up-auth check that requires
  // `amr.includes("mfa")`.
  const payload = buildJwtPayload(user, req.authUser.workspaceId, { amr: req.authUser.amr });
  const token = signJwt(payload, getJwtSecret());
  const exp   = Math.floor(Date.now() / 1000) + JWT_TTL_SEC;
  setAuthCookie(res, token, exp);

  return res.json({ user: buildUserResponse(user, req.authUser.workspaceId) });
});

// ─── Email Verification (SEC-001) ────────────────────────────────────────────

/**
 * Verify a user's email address using a signed token.
 * Called when the user clicks the verification link in their email.
 *
 * @route GET /api/v1/auth/verify
 * @param {string} req.query.token - The verification token from the email.
 * @returns {200} `{ message, verified: true }` on success.
 * @returns {400} Invalid, expired, or already-used token.
 */
router.get("/verify", async (req, res) => {
  const { token } = req.query;

  if (!token || typeof token !== "string") {
    return res.status(400).json({ error: "Verification token is required." });
  }

  let entry;
  try {
    entry = verificationTokenRepo.claim(token);
  } catch (dbErr) {
    console.error(formatLogLine("error", null, `[auth/verify] DB error: ${dbErr.message}`));
    return res.status(500).json({ error: "Server error. Please try again." });
  }

  if (!entry) {
    return res.status(400).json({ error: "Invalid or expired verification link. Please request a new one." });
  }

  const user = userRepo.getById(entry.userId);
  if (!user) {
    return res.status(400).json({ error: "Account not found." });
  }

  // Mark user as verified
  userRepo.update(user.id, { emailVerified: 1, updatedAt: new Date().toISOString() });

  // Clean up any remaining unused tokens for this user
  try {
    verificationTokenRepo.deleteUnusedByUserId(entry.userId);
  } catch (dbErr) {
    console.error(formatLogLine("error", null, `[auth/verify] Token cleanup error: ${dbErr.message}`));
  }

  if (process.env.NODE_ENV !== "production") {
    console.log(formatLogLine("info", null, `[auth/verify] Email verified for ${user.email}`));
  }

  return res.json({ message: "Email verified successfully. You can now sign in.", verified: true });
});

/**
 * Resend the verification email for an unverified account.
 * Rate-limited to prevent abuse.
 *
 * @route POST /api/v1/auth/resend-verification
 * @param {Object} req.body
 * @param {string} req.body.email - Email address of the unverified account.
 * @returns {200} `{ message }` — always returns success to prevent enumeration.
 */
router.post("/resend-verification", async (req, res) => {
  const ip = req.ip || "unknown";
  const rate = checkRateLimit("forgotPassword", ip); // reuse the same bucket
  if (!rate.allowed) {
    res.setHeader("Retry-After", rate.retryAfterSec);
    return res.status(429).json({ error: `Too many requests. Try again in ${Math.ceil(rate.retryAfterSec / 60)} minutes.` });
  }

  const email = sanitiseString(req.body.email, 254).toLowerCase();
  if (!isValidEmail(email)) {
    return res.status(400).json({ error: "A valid email address is required." });
  }

  const genericMsg = "If an unverified account with that email exists, a verification link has been sent.";

  const user = userRepo.getByEmail(email);
  if (!user || user.emailVerified !== 0) {
    // No account or already verified — silently succeed to prevent enumeration
    return res.json({ message: genericMsg });
  }

  const verifyToken = crypto.randomBytes(32).toString("base64url");
  const tokenExpiresAt = new Date(Date.now() + VERIFICATION_TOKEN_TTL_MS).toISOString();
  try {
    verificationTokenRepo.create(verifyToken, user.id, email, tokenExpiresAt);
    await sendVerificationEmail(email, verifyToken, user.name);
  } catch (emailErr) {
    console.error(formatLogLine("error", null, `[auth/resend-verification] Failed to send: ${emailErr.message}`));
    return res.status(500).json({ error: "Failed to send verification email. Please try again." });
  }

  return res.json({ message: genericMsg });
});

// ─── Password Reset ──────────────────────────────────────────────────────────

/**
 * Request a password reset. Generates a time-limited token.
 * In production this would send an email; in dev the token is returned
 * in the response and logged to console for convenience.
 *
 * Always returns 200 regardless of whether the email exists to prevent
 * user enumeration.
 *
 * @route POST /api/v1/auth/forgot-password
 * @param {Object} req.body
 * @param {string} req.body.email - Email address of the account.
 * @returns {200} `{ message, ...(dev: resetToken, resetUrl) }`.
 */
router.post("/forgot-password", async (req, res) => {
  // Rate-limit to prevent token-flooding DoS (fills memory with reset tokens)
  const ip = req.ip || "unknown";
  const rate = checkRateLimit("forgotPassword", ip);
  if (!rate.allowed) {
    res.setHeader("Retry-After", rate.retryAfterSec);
    return res.status(429).json({ error: `Too many requests. Try again in ${Math.ceil(rate.retryAfterSec / 60)} minutes.` });
  }

  const email = sanitiseString(req.body.email, 254).toLowerCase();
  if (!isValidEmail(email)) {
    return res.status(400).json({ error: "A valid email address is required." });
  }

  const user = userRepo.getByEmail(email);

  // Always return success to prevent user enumeration
  const genericMsg = "If an account with that email exists, a password reset link has been generated.";

  if (!user || !user.passwordHash) {
    // No account or OAuth-only account — silently succeed
    return res.json({ message: genericMsg });
  }

  // Generate a cryptographically random reset token and persist it to the DB.
  // Storing tokens in the DB (migration 003) means they survive server restarts
  // and work correctly across multiple API instances.
  const resetToken = crypto.randomBytes(32).toString("base64url");
  const tokenExpiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS).toISOString();
  try {
    // Invalidate any existing unused tokens for this user before creating a new one
    resetTokenRepo.create(resetToken, user.id, tokenExpiresAt);
  } catch (dbErr) {
    console.error(formatLogLine("error", null, `[auth/forgot-password] DB error: ${dbErr.message}`));
    return res.status(500).json({ error: "Failed to generate reset token. Please try again." });
  }

  // In production: send email with resetUrl. For now, log + return in dev.
  const appUrl = process.env.APP_URL || "http://localhost:3000";
  const baseUrl = (process.env.APP_BASE_PATH || "/").replace(/\/$/, "");
  const resetUrl = `${appUrl}${baseUrl}/forgot-password?token=${resetToken}`;

  if (process.env.NODE_ENV !== "production") {
    console.log(`[auth/forgot-password] Reset token for ${email}: ${resetToken}`);
    console.log(`[auth/forgot-password] Reset URL: ${resetUrl}`);
  }

  const response = { message: genericMsg };
  // Only expose the token in the response when explicitly opted-in.
  // Using NODE_ENV!=="production" was unsafe: staging servers without the flag
  // set would leak live reset tokens to any caller. ENABLE_DEV_RESET_TOKENS
  // must be deliberately set — absence of a production flag is not sufficient.
  if (process.env.ENABLE_DEV_RESET_TOKENS === "true") {
    response.resetToken = resetToken;
    response.resetUrl = resetUrl;
  }

  return res.json(response);
});

/**
 * Reset password using a valid reset token.
 *
 * @route POST /api/v1/auth/reset-password
 * @param {Object} req.body
 * @param {string} req.body.token       - The reset token from the email/URL.
 * @param {string} req.body.newPassword - New password (8–128 chars).
 * @returns {200} `{ message }` on success.
 * @returns {400} Invalid token, expired, or validation error.
 */
router.post("/reset-password", async (req, res) => {
  // Rate-limit to prevent brute-force token guessing
  const ip = req.ip || "unknown";
  const rate = checkRateLimit("resetPassword", ip);
  if (!rate.allowed) {
    res.setHeader("Retry-After", rate.retryAfterSec);
    return res.status(429).json({ error: `Too many requests. Try again in ${Math.ceil(rate.retryAfterSec / 60)} minutes.` });
  }

  const { token, newPassword } = req.body;

  if (!token || typeof token !== "string") {
    return res.status(400).json({ error: "Reset token is required." });
  }
  const pwErr = validatePasswordStrength(newPassword);
  if (pwErr) {
    return res.status(400).json({ error: pwErr });
  }

  // Atomically claim the token — marks it as used in a single UPDATE so two
  // concurrent requests with the same token cannot both succeed (TOCTOU fix).
  let entry;
  try {
    entry = resetTokenRepo.claim(token);
  } catch (dbErr) {
    console.error(formatLogLine("error", null, `[auth/reset-password] DB error: ${dbErr.message}`));
    return res.status(500).json({ error: "Server error. Please try again." });
  }

  if (!entry) {
    return res.status(400).json({ error: "Invalid or expired reset token. Please request a new one." });
  }

  const user = userRepo.getById(entry.userId);
  if (!user) {
    return res.status(400).json({ error: "Account not found." });
  }

  // Update password
  const newHash = await hashPassword(newPassword);
  userRepo.update(user.id, { passwordHash: newHash, updatedAt: new Date().toISOString() });

  // Invalidate all other unused tokens for this user so old reset links
  // cannot be replayed. The current token is already marked as used by claim().
  try {
    resetTokenRepo.deleteUnusedByUserId(entry.userId);
  } catch (dbErr) {
    // Non-fatal — password was already changed; just log the cleanup failure.
    console.error(formatLogLine("error", null, `[auth/reset-password] Token cleanup error: ${dbErr.message}`));
  }

  if (process.env.NODE_ENV !== "production") {
    console.log(`[auth/reset-password] Password reset for ${user.email}`);
  }

  // SEC-007: `/reset-password` is a public endpoint so `req.workspaceId` is
  // unset. Resolve the user's primary workspace from their membership (same
  // pattern as the MFA pending-login flow above) so the reset event is
  // visible in the workspace-scoped audit log rather than orphaned with
  // `workspaceId = NULL`.
  const resetWorkspaceId = req.workspaceId || workspaceRepo.getByUserId(user.id)?.[0]?.id || null;
  logActivity({ type: "auth.password.reset", req, userId: user.id, userName: user.name || user.email, workspaceId: resetWorkspaceId });
  return res.json({ message: "Password has been reset successfully. You can now sign in." });
});

// ─── GitHub OAuth ─────────────────────────────────────────────────────────────

/**
 * GitHub OAuth callback. Exchanges an authorization code for an access token,
 * fetches the user profile, sets auth cookies, and returns the user profile.
 *
 * @route GET /api/v1/auth/github/callback
 * @param {string} req.query.code - The OAuth authorization code from GitHub.
 * @returns {200} `{ user: { id, name, email, role, avatar, workspaceId, workspaceName, workspaceRole } }`.
 * @returns {400} Missing code parameter.
 * @returns {401} Token exchange or profile fetch failed.
 * @returns {503} GitHub OAuth not configured on this server.
 */
router.get("/github/callback", async (req, res) => {
  const code  = req.query.code;
  if (!code) return res.status(400).json({ error: "Missing code parameter." });

  const clientId     = process.env.GITHUB_CLIENT_ID;
  const clientSecret = process.env.GITHUB_CLIENT_SECRET;
  if (!clientId || !clientSecret) {
    return res.status(503).json({ error: "GitHub OAuth is not configured on this server." });
  }

  try {
    // Exchange code for access token
    const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code }),
    });
    const tokenData = await tokenRes.json();
    if (tokenData.error || !tokenData.access_token) {
      throw new Error(tokenData.error_description || "GitHub token exchange failed.");
    }

    // Fetch user profile
    const profileRes = await fetch("https://api.github.com/user", {
      headers: { Authorization: `Bearer ${tokenData.access_token}`, "User-Agent": "Sentri-App" },
    });
    const profile = await profileRes.json();

    // Fetch primary email if not public
    let email = profile.email;
    if (!email) {
      const emailsRes = await fetch("https://api.github.com/user/emails", {
        headers: { Authorization: `Bearer ${tokenData.access_token}`, "User-Agent": "Sentri-App" },
      });
      const emails = await emailsRes.json();
      email = emails.find(e => e.primary && e.verified)?.email || emails[0]?.email;
    }
    if (!email) throw new Error("Could not retrieve a verified email from GitHub.");

    const user = await findOrCreateOAuthUser({
      provider: "github",
      providerId: String(profile.id),
      email: email.toLowerCase(),
      name: profile.name || profile.login,
      avatar: profile.avatar_url || null,
    });

    ensureUserWorkspace(user);

    // SEC-004: enforcement applies to OAuth too. OAuth-only users have no
    // password but still need MFA when the workspace policy demands it.
    if (applyMfaEnforcement(req, res, user, { method: "oauth", provider: "github" }, "OAuth login blocked: workspace requires MFA.")) return;

    // SEC-004 §5c: OAuth sessions are tagged amr=["oauth"]. They are NOT
    // MFA-asserted — workspace MFA enforcement applies above.
    const payload = buildJwtPayload(user, undefined, { amr: ["oauth"] });
    const token = signJwt(payload, getJwtSecret());
    const exp   = Math.floor(Date.now() / 1000) + JWT_TTL_SEC;
    setAuthCookie(res, token, exp);
    logActivity({ type: "auth.login", req, userId: user.id, userName: user.name || user.email, workspaceId: payload.workspaceId });

    return res.json({ user: buildUserResponse(user) });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/github] ${err.message}`));
    return res.status(401).json({ error: err.message || "GitHub authentication failed." });
  }
});

// ─── Google OAuth ─────────────────────────────────────────────────────────────

/**
 * Google OAuth callback. Exchanges an authorization code for an access token,
 * fetches the user profile, sets auth cookies, and returns the user profile.
 *
 * @route GET /api/v1/auth/google/callback
 * @param {string} req.query.code - The OAuth authorization code from Google.
 * @returns {200} `{ user: { id, name, email, role, avatar, workspaceId, workspaceName, workspaceRole } }`.
 * @returns {400} Missing code parameter.
 * @returns {401} Token exchange or profile fetch failed.
 * @returns {503} Google OAuth not configured on this server.
 */
router.get("/google/callback", async (req, res) => {
  const code = req.query.code;
  if (!code) return res.status(400).json({ error: "Missing code parameter." });

  const clientId     = process.env.GOOGLE_CLIENT_ID;
  const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
  const redirectUri  = process.env.GOOGLE_REDIRECT_URI || `${process.env.APP_URL || "http://localhost:3000"}/login?provider=google`;

  if (!clientId || !clientSecret) {
    return res.status(503).json({ error: "Google OAuth is not configured on this server." });
  }

  try {
    // Exchange code for tokens
    const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, grant_type: "authorization_code" }),
    });
    const tokenData = await tokenRes.json();
    if (!tokenData.access_token) {
      throw new Error(tokenData.error_description || "Google token exchange failed.");
    }

    // Fetch user info
    const profileRes = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", {
      headers: { Authorization: `Bearer ${tokenData.access_token}` },
    });
    const profile = await profileRes.json();

    if (!profile.email_verified) throw new Error("Google account email is not verified.");

    const user = await findOrCreateOAuthUser({
      provider: "google",
      providerId: profile.sub,
      email: profile.email.toLowerCase(),
      name: profile.name || profile.email.split("@")[0],
      avatar: profile.picture || null,
    });

    ensureUserWorkspace(user);

    // SEC-004: enforcement applies to OAuth too. OAuth-only users have no
    // password but still need MFA when the workspace policy demands it.
    if (applyMfaEnforcement(req, res, user, { method: "oauth", provider: "google" }, "OAuth login blocked: workspace requires MFA.")) return;

    // SEC-004 §5c: OAuth sessions are tagged amr=["oauth"]. They are NOT
    // MFA-asserted — workspace MFA enforcement applies above.
    const payload = buildJwtPayload(user, undefined, { amr: ["oauth"] });
    const token = signJwt(payload, getJwtSecret());
    const exp   = Math.floor(Date.now() / 1000) + JWT_TTL_SEC;
    setAuthCookie(res, token, exp);
    logActivity({ type: "auth.login", req, userId: user.id, userName: user.name || user.email, workspaceId: payload.workspaceId });

    return res.json({ user: buildUserResponse(user) });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/google] ${err.message}`));
    return res.status(401).json({ error: err.message || "Google authentication failed." });
  }
});

// ─── Shared OAuth helper ──────────────────────────────────────────────────────

/**
 * Find an existing user by OAuth provider ID or email, or create a new one.
 * If a user with the same email exists (registered via a different provider),
 * the accounts are linked automatically.
 *
 * @param {Object} opts
 * @param {string} opts.provider   - OAuth provider name (e.g. `"github"`, `"google"`).
 * @param {string} opts.providerId - Provider-specific user ID.
 * @param {string} opts.email      - User's email (lowercased).
 * @param {string} opts.name       - Display name.
 * @param {string|null} opts.avatar - Avatar URL, or `null`.
 * @returns {Promise<User>}          The found or newly created user object.
 * @private
 */
async function findOrCreateOAuthUser({ provider, providerId, email, name, avatar }) {
  const key      = `${provider}:${providerId}`;
  let userId     = userRepo.getOAuthUserId(key);
  let user       = userId ? userRepo.getById(userId) : null;

  if (!user) {
    // Check if an account with this email exists (link providers)
    user = userRepo.getByEmail(email);
  }

  if (!user) {
    // Create new user
    const id  = crypto.randomUUID();
    const now = new Date().toISOString();
    user      = { id, name, email, passwordHash: null, role: "user", avatar, createdAt: now, updatedAt: now };
    userRepo.create(user);
  }

  // Always keep OAuth provider link up to date
  userRepo.setOAuthLink(key, user.id);

  // SEC-001: If the user registered via email/password but hasn't verified yet,
  // auto-verify them now — the OAuth provider already verified the email.
  // This prevents the scenario where a user registers, then logs in via OAuth,
  // but can never use email/password login because emailVerified stays 0.
  const updates = {};
  if (user.emailVerified === 0) {
    updates.emailVerified = 1;
  }
  // Update avatar if missing
  if (!user.avatar && avatar) {
    updates.avatar = avatar;
    user.avatar = avatar;
  }
  if (Object.keys(updates).length > 0) {
    updates.updatedAt = new Date().toISOString();
    userRepo.update(user.id, updates);
    if (updates.emailVerified) user.emailVerified = 1;
  }

  return user;
}


// ─── SEC-004: MFA management endpoints ───────────────────────────────────────

/**
 * Report whether the authenticated user has TOTP MFA enabled.
 * @route GET /api/v1/auth/mfa/status
 */
router.get("/mfa/status", requireAuth, (req, res) => {
  try {
    const user = userRepo.getById(req.authUser.sub);
    if (!user) return res.status(404).json({ error: "User not found." });
    return res.json({ enabled: user.mfaEnabled === 1 });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/mfa/status] ${err.message}`));
    return res.status(500).json({ error: "Failed to fetch MFA status." });
  }
});

/**
 * Aggregate view of all second-factor state for the Settings UI — one
 * round trip instead of three (status + recovery count + passkey list).
 *
 * @route GET /api/v1/auth/mfa/factors
 * @returns {200} `{
 *   totp: boolean,
 *   recoveryCodesRemaining: number,
 *   webauthn: [{ id, deviceName, transports, createdAt, lastUsedAt }]
 * }`
 */
router.get("/mfa/factors", requireAuth, (req, res) => {
  try {
    const user = userRepo.getById(req.authUser.sub);
    if (!user) return res.status(404).json({ error: "User not found." });

    let recoveryCodesRemaining = 0;
    if (user.mfaEnabled === 1 && user.mfaRecoveryCodes) {
      try {
        const codes = JSON.parse(user.mfaRecoveryCodes);
        if (Array.isArray(codes)) recoveryCodesRemaining = codes.length;
      } catch { /* malformed JSON — count as zero */ }
    }

    const credentials = webauthnRepo.listByUser(user.id).map((c) => ({
      id: c.id,
      deviceName: c.deviceName,
      transports: c.transports,
      createdAt: c.createdAt,
      lastUsedAt: c.lastUsedAt,
    }));

    return res.json({
      totp: user.mfaEnabled === 1,
      recoveryCodesRemaining,
      webauthn: credentials,
    });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/mfa/factors] ${err.message}`));
    return res.status(500).json({ error: "Failed to fetch MFA factors." });
  }
});

/**
 * Begin TOTP enrollment — generate a fresh secret + otpauth URL for the
 * user's authenticator app. The secret is encrypted at rest (AES-GCM via
 * `credentialEncryption.js`) and the user is NOT marked enabled until they
 * confirm a valid code via `/mfa/enable`.
 *
 * Refuses re-enrollment when MFA is already active (SEC-004 §2a) so a
 * stolen-cookie attacker cannot silently replace the legitimate user's
 * secret. Rate-limited (3 per IP per 15 min) to prevent secret-flooding.
 *
 * @route POST /api/v1/auth/mfa/enroll
 * @returns {200} `{ secret, otpauth }`
 * @returns {409} MFA already enabled.
 * @returns {429} Rate limit exceeded.
 */
router.post("/mfa/enroll", requireAuth, (req, res) => {
  const ip = req.ip || "unknown";
  const rate = checkRateLimit("mfaEnroll", ip);
  if (!rate.allowed) {
    res.setHeader("Retry-After", rate.retryAfterSec);
    return res.status(429).json({ error: `Too many enrollment attempts. Try again in ${Math.ceil(rate.retryAfterSec / 60)} minutes.` });
  }

  try {
    const user = userRepo.getById(req.authUser.sub);
    if (!user) return res.status(404).json({ error: "User not found." });
    if (user.mfaEnabled === 1) {
      return res.status(409).json({ error: "MFA is already enabled. Disable it first to re-enroll." });
    }

    const secret = generateTotpSecret();
    const otpauth = `otpauth://totp/Sentri:${encodeURIComponent(user.email)}?secret=${secret}&issuer=Sentri&algorithm=SHA1&digits=6&period=30`;
    userRepo.update(user.id, { mfaSecret: encryptString(secret), mfaEnabled: 0, updatedAt: new Date().toISOString() });

    logActivity({
      type: "auth.mfa.enroll_started",
      req,
      detail: "Started TOTP enrollment.",
      userId: user.id, userName: user.name || user.email,
      workspaceId: req.authUser.workspaceId,
      meta: { method: "totp" },
    });

    return res.json({ secret, otpauth });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/mfa/enroll] ${err.message}`));
    return res.status(500).json({ error: "Failed to start MFA enrollment." });
  }
});

/**
 * Finalize TOTP enrollment by verifying the user's first code. On success
 * marks MFA enabled and returns single-use recovery codes (shown once).
 *
 * @route POST /api/v1/auth/mfa/enable
 * @param {Object} req.body
 * @param {string} req.body.token - 6-digit code from authenticator app.
 * @returns {200} `{ ok: true, recoveryCodes }`
 * @returns {400} Enrollment not initialized or wrong code.
 */
router.post("/mfa/enable", requireAuth, (req, res) => {
  // SEC-004 §6: rate-limit the enable path — an attacker with a stolen session
  // cookie could brute-force the 6-digit TOTP space during enrollment (1M codes).
  // Reuse the mfaVerify bucket (5/15min) since the threat model is identical.
  const ip = req.ip || "unknown";
  const rate = checkRateLimit("mfaVerify", ip);
  if (!rate.allowed) {
    res.setHeader("Retry-After", rate.retryAfterSec);
    return res.status(429).json({ error: `Too many verification attempts. Try again in ${Math.ceil(rate.retryAfterSec / 60)} minutes.` });
  }

  try {
    const user = userRepo.getById(req.authUser.sub);
    if (!user) return res.status(404).json({ error: "User not found." });
    // SEC-004 §2a: refuse to re-finalize when MFA is already on. Without
    // this, a stolen-cookie attacker (or even a confused legitimate user)
    // could call /enable a second time and silently replace the existing
    // recovery codes via mintRecoveryCodes() below — invalidating the codes
    // the user has already stored offline. /mfa/enroll has the same guard;
    // this is the symmetric check on the finalization step.
    if (user.mfaEnabled === 1) {
      return res.status(409).json({ error: "MFA is already enabled. Disable it first to re-enroll." });
    }
    const secret = decryptString(user.mfaSecret);
    if (!secret) return res.status(400).json({ error: "MFA enrollment is not initialized." });
    const token = sanitiseString(req.body?.token, 16).replace(/\s+/g, "");
    if (!verifyTotp(token, secret)) {
      logActivity({
        type: "auth.mfa.verify_failed",
        req,
        detail: "Invalid TOTP code during enable.",
        userId: user.id, userName: user.name || user.email,
        workspaceId: req.authUser.workspaceId,
        meta: { method: "totp", phase: "enable" },
      });
      return res.status(400).json({ error: "Invalid code." });
    }
    const recovery = mintRecoveryCodes();
    userRepo.update(user.id, { mfaEnabled: 1, mfaRecoveryCodes: JSON.stringify(recovery.hashed), updatedAt: new Date().toISOString() });

    logActivity({
      type: "auth.mfa.enabled",
      req,
      detail: "Enabled TOTP MFA.",
      userId: user.id, userName: user.name || user.email,
      workspaceId: req.authUser.workspaceId,
      meta: { method: "totp", recoveryCodesIssued: recovery.raw.length },
    });

    return res.json({ ok: true, recoveryCodes: recovery.raw });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/mfa/enable] ${err.message}`));
    return res.status(500).json({ error: "Failed to enable MFA." });
  }
});

/**
 * Verify the second factor during login. Accepts either a 6-digit TOTP code
 * or a single-use recovery code. On success, issues the auth cookie with an
 * MFA-asserted JWT (`amr: ["pwd","mfa"]`).
 *
 * Public route — authenticated by the `pendingToken` issued by `/login`.
 * Rate-limited per IP (5 attempts / 15 min) to bound brute force on the
 * 6-digit TOTP / recovery-code spaces. Recovery-code lookup uses a
 * constant-time scan (SEC-004 §5b) so timing does not leak which slot
 * matched.
 *
 * @route POST /api/v1/auth/mfa/verify
 * @param {Object} req.body
 * @param {string} req.body.pendingToken
 * @param {string} req.body.token
 * @returns {200} `{ user }`
 * @returns {400} Invalid code.
 * @returns {401} Pending token missing / expired.
 * @returns {429} Rate limit exceeded.
 */
router.post("/mfa/verify", async (req, res) => {
  const ip = req.ip || "unknown";
  const rate = checkRateLimit("mfaVerify", ip);
  if (!rate.allowed) {
    res.setHeader("Retry-After", rate.retryAfterSec);
    return res.status(429).json({ error: `Too many verification attempts. Try again in ${Math.ceil(rate.retryAfterSec / 60)} minutes.` });
  }

  try {
    // SEC-004: PEEK (don't consume) the pending token first so a wrong TOTP
    // or recovery code does not waste the user's MFA session. The token is
    // only consumed at the bottom of the success path. Matches the WebAuthn
    // flow at routes/webauthn.js: defer consume until verification passes,
    // letting the rate limiter (5/15min) absorb retries instead of forcing a
    // full re-login on every typo.
    const pendingTokenStr = sanitiseString(req.body?.pendingToken, 200);
    const pending = consumePendingMfaLogin(pendingTokenStr, { peek: true });
    if (!pending) return res.status(401).json({ error: "MFA session expired. Sign in again." });
    const user = userRepo.getById(pending.userId);
    // Return 401 (not 404) to avoid leaking whether the underlying user still
    // exists via a server-issued pendingToken. The token is short-lived so this
    // is low-severity, but defense-in-depth matches the expired-token path.
    if (!user) return res.status(401).json({ error: "MFA session expired. Sign in again." });
    const token = sanitiseString(req.body?.token, 16).replace(/\s+/g, "");

    // Attempt TOTP verification only when the secret is decryptable.
    // If the secret is corrupted / key-rotated, skip TOTP but still allow
    // the recovery-code path below — that's the entire purpose of recovery
    // codes (they're SHA-256 hashed independently of the AES-encrypted secret).
    const secret = decryptString(user.mfaSecret);
    let ok = false;
    let method = null;
    let updatedRecovery = null;
    let remaining = null;

    if (secret && token) {
      ok = verifyTotp(token, secret);
      if (ok) method = "totp";
    }

    if (!ok && token) {
      // Recovery-code path: constant-time scan to avoid leaking which slot
      // matched via the difference between Array.indexOf early-exit and full
      // scan timings.
      //
      // Normalise the candidate to lowercase before hashing. `mintRecoveryCodes`
      // emits lowercase hex (`crypto.randomBytes(4).toString("hex")`) but mobile
      // keyboards frequently auto-capitalize the first character and users
      // retyping from memory often use uppercase. Without this, "ABC123EF" and
      // "abc123ef" hash to different values and the user gets a generic 400
      // error with no clue why. SHA-256 is one-way so we cannot recover from
      // case at the storage end — normalise at compare time.
      const hashed = hashRecoveryCode(token.toLowerCase());
      // Tolerate malformed `mfaRecoveryCodes` JSON: a corrupted column should
      // produce a clean 400 "Invalid authentication code" (via the !ok branch
      // below), not a 500 from JSON.parse throwing into the outer try/catch.
      // The symmetric branch at /mfa/factors (~line 1454) and the
      // not-configured guard below already use the same try/catch shape.
      let codes = [];
      try {
        const parsed = JSON.parse(user.mfaRecoveryCodes || "[]");
        if (Array.isArray(parsed)) codes = parsed;
      } catch { /* malformed JSON — treat as no codes */ }
      const idx = findRecoveryCodeIndex(codes, hashed);
      if (idx >= 0) {
        codes.splice(idx, 1);
        updatedRecovery = JSON.stringify(codes);
        remaining = codes.length;
        ok = true;
        method = "recovery";
      }
    }

    // If BOTH the TOTP secret is undecryptable AND no recovery codes exist,
    // surface a clear error rather than the generic "Invalid authentication
    // code" — the user's MFA row is broken and they need admin help.
    // Parse the JSON rather than comparing the literal string "[]" — the
    // column could contain whitespace-variant JSON like "[ ]" or be null.
    if (!ok && !secret) {
      let hasRecoveryCodes = false;
      try {
        const parsed = JSON.parse(user.mfaRecoveryCodes || "[]");
        hasRecoveryCodes = Array.isArray(parsed) && parsed.length > 0;
      } catch { /* malformed JSON — treat as no codes */ }
      if (!hasRecoveryCodes) {
        return res.status(400).json({ error: "MFA is not configured. Contact an administrator." });
      }
    }

    if (!ok) {
      // SEC-004 §5d: bump the per-pendingToken failure counter. The IP
      // rate limiter (5/15min) doesn't bound an attacker who rotates IPs;
      // the token-bound counter does. After `MFA_MAX_ATTEMPTS` failures
      // the token is consumed and the user must re-enter password+email.
      const strike = recordPendingMfaFailure(pendingTokenStr);
      logActivity({
        type: "auth.mfa.verify_failed",
        req,
        detail: strike?.exhausted
          ? "Pending MFA token exhausted after repeated failures."
          : "Invalid MFA code during login.",
        userId: user.id, userName: user.name || user.email,
        workspaceId: pending.workspaceId,
        meta: {
          method: token ? "unknown" : "missing",
          phase: "login",
          remainingAttempts: strike?.remaining ?? null,
          exhausted: strike?.exhausted || false,
        },
      });
      if (strike?.exhausted) {
        // Same 401 + message as the expired-token path so an attacker
        // cannot distinguish "out of attempts" from "session expired" —
        // both require re-running the password step to recover.
        return res.status(401).json({ error: "MFA session expired. Sign in again." });
      }
      return res.status(400).json({ error: "Invalid authentication code." });
    }

    // Verification passed — consume the token now, atomically. If a parallel
    // request raced and already consumed it, treat this as expired.
    if (!consumePendingMfaLogin(pendingTokenStr)) {
      return res.status(401).json({ error: "MFA session expired. Sign in again." });
    }

    if (updatedRecovery !== null) {
      userRepo.update(user.id, { mfaRecoveryCodes: updatedRecovery, updatedAt: new Date().toISOString() });
      logActivity({
        type: "auth.mfa.recovery_code_consumed",
        req,
        detail: "Consumed a recovery code during MFA login.",
        userId: user.id, userName: user.name || user.email,
        workspaceId: pending.workspaceId,
        meta: { remaining },
      });
    } else {
      logActivity({
        type: "auth.mfa.login_verified",
        req,
        detail: "MFA login verified.",
        userId: user.id, userName: user.name || user.email,
        workspaceId: pending.workspaceId,
        meta: { method },
      });
    }

    // SEC-004 §5c: MFA-asserted session — tag with both pwd (login flow
    // already proved the password) and mfa (second factor verified).
    //
    // NOTE: applyMfaEnforcement is intentionally NOT called here. The
    // invariant is: this branch is only reachable when the user has at
    // least one MFA factor (TOTP/recovery/passkey), and
    // evaluateMfaEnforcement returns "allow" the moment any factor is
    // present. If a future rule expands enforcement to require something
    // beyond "has any factor" (e.g. "factor must be a hardware key"), the
    // applyMfaEnforcement call must be added back here — leave this
    // comment as the contract reminder.
    const payload = buildJwtPayload(user, undefined, { amr: ["pwd", "mfa"] });
    const jwt = signJwt(payload, getJwtSecret());
    const exp = Math.floor(Date.now() / 1000) + JWT_TTL_SEC;
    setAuthCookie(res, jwt, exp);

    return res.json({ user: buildUserResponse(user) });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/mfa/verify] ${err.message}`));
    return res.status(500).json({ error: "Failed to verify MFA." });
  }
});

/**
 * Regenerate the user's recovery codes. Invalidates the previous set and
 * returns a fresh batch (shown once — the client must save them immediately).
 * Requires password confirmation; OAuth-only users authenticate by session.
 *
 * Audit-logged so admins can correlate "I lost my codes" support tickets
 * with the regeneration event.
 *
 * @route POST /api/v1/auth/mfa/recovery-codes/regenerate
 * @param {Object} req.body
 * @param {string} req.body.password
 * @returns {200} `{ recoveryCodes: string[] }`
 * @returns {400} MFA not enabled.
 * @returns {403} Password confirmation failed.
 */
router.post("/mfa/recovery-codes/regenerate", requireAuth, async (req, res) => {
  try {
    const user = userRepo.getById(req.authUser.sub);
    if (!user) return res.status(404).json({ error: "User not found." });
    if (user.mfaEnabled !== 1) {
      return res.status(400).json({ error: "MFA is not enabled. Enable MFA before regenerating recovery codes." });
    }
    const valid = await verifyAccountPassword(user, req.body?.password);
    if (!valid) return res.status(403).json({ error: "Password confirmation failed." });

    const recovery = mintRecoveryCodes();
    userRepo.update(user.id, {
      mfaRecoveryCodes: JSON.stringify(recovery.hashed),
      updatedAt: new Date().toISOString(),
    });

    logActivity({
      type: "auth.mfa.recovery_codes_regenerated",
      req,
      detail: "Regenerated MFA recovery codes.",
      userId: user.id, userName: user.name || user.email,
      workspaceId: req.authUser.workspaceId,
      meta: { count: recovery.raw.length },
    });

    // SEC-004: Regenerating recovery codes is the user's "panic button" — the
    // typical trigger is "my old codes leaked" or "I lost them and might've
    // exposed them". Revoke the current session so a parallel cookie on
    // another device (e.g. an attacker holding the one that triggered the
    // panic) cannot continue acting under the regenerated set. Matches the
    // industry baseline (Auth0, Clerk, Okta, GitHub all terminate sessions
    // on credential change).
    _internalRevokeCurrentSession(req, res, { reason: "mfa.recovery_codes_regenerated" });

    return res.json({ recoveryCodes: recovery.raw, sessionRevoked: true });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/mfa/recovery-codes/regenerate] ${err.message}`));
    return res.status(500).json({ error: "Failed to regenerate recovery codes." });
  }
});

/**
 * Disable MFA. Clears the encrypted secret and recovery codes. Requires
 * password confirmation (OAuth-only users authenticate by session — see
 * {@link verifyAccountPassword}).
 *
 * @route POST /api/v1/auth/mfa/disable
 * @param {Object} req.body
 * @param {string} req.body.password
 * @returns {200} `{ ok: true }`
 * @returns {403} Password confirmation failed.
 */
router.post("/mfa/disable", requireAuth, async (req, res) => {
  try {
    const user = userRepo.getById(req.authUser.sub);
    if (!user) return res.status(404).json({ error: "User not found." });
    const valid = await verifyAccountPassword(user, req.body?.password);
    if (!valid) return res.status(403).json({ error: "Password confirmation failed." });

    // SEC-004: self-lockout guard. If TOTP is the user's only second factor
    // (no passkeys) and a workspace they belong to requires MFA, refuse the
    // disable — for BOTH "block" (past grace) AND "grace" (within grace).
    // Allowing the disable during grace would just defer the lockout: the
    // user drops to zero factors and walks into a 403 on day N+1. Mirror of
    // the guard in DELETE /webauthn/credentials/:id; passes
    // `skipWebauthnCheck: true` so a 0-passkey user is correctly seen as
    // factor-less (the live passkey count is already 0 here, so the flag
    // is defensive — it future-proofs against accidental reordering).
    const passkeyCount = webauthnRepo.countByUser(user.id);
    if (passkeyCount === 0) {
      const enforcement = evaluateMfaEnforcement({ ...user, mfaEnabled: 0 }, { skipWebauthnCheck: true });
      if (enforcement.state !== "allow") {
        const inGrace = enforcement.state === "grace";
        return res.status(400).json({
          error: inGrace
            ? `Cannot disable MFA — your workspace will require it in ${enforcement.gracePeriodDaysRemaining} day${enforcement.gracePeriodDaysRemaining === 1 ? "" : "s"}. Enroll a passkey first.`
            : "Cannot disable MFA — your workspace requires it. Enroll a passkey first, or ask an administrator to extend the grace window.",
          code: "MFA_LAST_FACTOR_PROTECTED",
          workspaceId: enforcement.workspaceId,
          gracePeriodDaysRemaining: enforcement.gracePeriodDaysRemaining,
        });
      }
    }

    userRepo.update(user.id, { mfaEnabled: 0, mfaSecret: null, mfaRecoveryCodes: null, updatedAt: new Date().toISOString() });

    logActivity({
      type: "auth.mfa.disabled",
      req,
      detail: "Disabled MFA.",
      userId: user.id, userName: user.name || user.email,
      workspaceId: req.authUser.workspaceId,
      meta: {},
    });

    // SEC-004: Disabling MFA is a security-posture downgrade — the user's
    // session is currently MFA-asserted (amr=["pwd","mfa"]) but after this
    // call the user no longer has a second factor. Revoking the current
    // session forces re-authentication so the new cookie reflects the new
    // posture (amr=["pwd"]). Matches the industry baseline for security-
    // impacting account changes.
    _internalRevokeCurrentSession(req, res, { reason: "mfa.disabled" });

    return res.json({ ok: true, sessionRevoked: true });
  } catch (err) {
    console.error(formatLogLine("error", null, `[auth/mfa/disable] ${err.message}`));
    return res.status(500).json({ error: "Failed to disable MFA." });
  }
});

export default router;