Source: utils/mfaEnforcement.js

/**
 * @module utils/mfaEnforcement
 * @description Per-workspace MFA enforcement helpers (SEC-004).
 *
 * Workspaces with `mfaRequired = 1` (migration 028) block login for any
 * member whose user account has `mfaEnabled = 0` once they are past the
 * grace window. The grace window starts at the later of:
 *   - the workspace's `mfaPolicyUpdatedAt` (when the policy flipped on)
 *   - the membership's `joinedAt` (so a new hire still gets a fair window
 *     even if the policy has been in place for months)
 * and lasts `mfaGracePeriodDays` calendar days.
 *
 * A user who belongs to multiple workspaces is enforced by the strictest
 * one: if ANY workspace they belong to is past-grace and requires MFA,
 * login is blocked.
 *
 * ### Exports
 * - {@link evaluateMfaEnforcement} — Decide enforce / grace / allow for a user.
 */
import * as workspaceRepo from "../database/repositories/workspaceRepo.js";
import * as webauthnRepo from "../database/repositories/webauthnRepo.js";

const MS_PER_DAY = 24 * 60 * 60 * 1000;

/**
 * @typedef {Object} MfaEnforcementDecision
 * @property {"allow"|"grace"|"block"} state - Final decision.
 * @property {string} [workspaceId] - Workspace that produced the decision (block / grace only).
 * @property {string} [workspaceName] - Display name for the same workspace.
 * @property {number} [gracePeriodDaysRemaining] - Ceil-rounded days left when state="grace".
 * @property {string} [graceEndsAt] - ISO timestamp when the user becomes blocked.
 */

/**
 * Evaluate whether MFA enforcement blocks, grace-warns, or allows a login
 * for a user. Pure function — no side effects, safe to call from any auth
 * surface (`/login`, `/refresh`, OAuth callbacks).
 *
 * Users are "MFA-enabled" if EITHER `mfaEnabled === 1` (TOTP) OR they have at
 * least one registered WebAuthn credential. Either factor satisfies the
 * workspace requirement — otherwise an OAuth-only user with a passkey but no
 * TOTP would be falsely blocked at OAuth callback time.
 *
 * @param {Object} user - User row (must include `id`, `mfaEnabled`, `createdAt`).
 * @param {Object} [opts]
 * @param {boolean} [opts.skipWebauthnCheck=false] — If `true`, do NOT count
 *   the user's registered passkeys as a satisfying factor. Used by the
 *   self-lockout guard in `DELETE /webauthn/credentials/:id`, which needs to
 *   ask "would this user be blocked if the passkey they're trying to remove
 *   were their only factor?". The caller has already verified TOTP is off
 *   and that this is their last passkey, so the live `webauthnRepo.countByUser`
 *   read (which still sees the passkey since it hasn't been deleted yet)
 *   would falsely return `"allow"` and let the user lock themselves out.
 *
 * ### Self-lockout guard contract
 * Callers that use this to decide whether to allow a factor-removal action
 * (e.g. `/mfa/disable`, `DELETE /webauthn/credentials/:id`) MUST refuse the
 * action for BOTH `"block"` and `"grace"` outcomes, not just `"block"`.
 * Permitting removal during grace defers the lockout by N days but does not
 * prevent it — the user signs out and is blocked at next login. Only the
 * `"allow"` state (no workspace requires MFA) permits factor removal.
 *
 * @returns {MfaEnforcementDecision}
 */
export function evaluateMfaEnforcement(user, opts = {}) {
  if (!user) return { state: "allow" };
  if (user.mfaEnabled === 1) return { state: "allow" };
  // SEC-004 — a registered passkey is just as strong a second factor as TOTP,
  // so it satisfies workspace enforcement on its own. Checked second because
  // it requires a DB round-trip; TOTP enabled is the cheap fast-path above.
  // The self-lockout guard opts out via `skipWebauthnCheck` so it can ask
  // the hypothetical "no second factor" question.
  if (!opts.skipWebauthnCheck && webauthnRepo.countByUser(user.id) > 0) return { state: "allow" };

  const memberships = workspaceRepo.getByUserId(user.id);
  if (!memberships || memberships.length === 0) return { state: "allow" };

  const now = Date.now();
  /** @type {MfaEnforcementDecision} */
  let strictest = { state: "allow" };

  for (const ws of memberships) {
    if (ws.mfaRequired !== 1) continue;

    const graceDays = Math.max(0, Number.isFinite(ws.mfaGracePeriodDays) ? ws.mfaGracePeriodDays : 7);
    // Grace clock starts at MAX(policy-flipped, user-joined, account-created).
    // `joinedAt` is on the membership row (see workspaceRepo.getByUserId join).
    const policyAt = ws.mfaPolicyUpdatedAt ? Date.parse(ws.mfaPolicyUpdatedAt) : null;
    const joinedAt = ws.joinedAt ? Date.parse(ws.joinedAt) : null;
    const accountAt = user.createdAt ? Date.parse(user.createdAt) : null;
    const candidates = [policyAt, joinedAt, accountAt].filter((v) => Number.isFinite(v));
    const graceStart = candidates.length > 0 ? Math.max(...candidates) : now;
    const graceEnd = graceStart + (graceDays * MS_PER_DAY);

    if (now >= graceEnd) {
      // Past grace — strictest possible outcome, short-circuit.
      return {
        state: "block",
        workspaceId: ws.id,
        workspaceName: ws.name,
        graceEndsAt: new Date(graceEnd).toISOString(),
      };
    }

    // Within grace — record as candidate; keep scanning in case another
    // workspace is already past grace (block wins over grace).
    const remaining = Math.ceil((graceEnd - now) / MS_PER_DAY);
    if (strictest.state === "allow" || (strictest.gracePeriodDaysRemaining ?? Infinity) > remaining) {
      strictest = {
        state: "grace",
        workspaceId: ws.id,
        workspaceName: ws.name,
        gracePeriodDaysRemaining: remaining,
        graceEndsAt: new Date(graceEnd).toISOString(),
      };
    }
  }

  return strictest;
}