Skip to main content

#1245 MFA Backend — Execution Plan

For agentic workers: execute via superpowers:subagent-driven-development. The authoritative spec is the on-branch checklist docs/proposals/1245-mfa-backend-implementation.md (§0–§7) + proposal docs/proposals/1245-mfa-backend.md. This file only defines the task grouping, commit boundaries, and acceptance so the checklist isn't duplicated.

Goal: Fill in the MFA backend business logic behind the landed scaffold (7 service methods currently throw NotImplementedException) + enforcement guard + rate-limit/lockout + audit + tests. Frontend is a separate follow-up (1245-mfa-frontend-implementation.md).

Branch: feat/1245-mfa-totp-backend (now merged up to current dev, 0 behind).

Scaffold already present (do NOT re-create):

  • Entities: User.{twoFactorSecretEncrypted(Buffer,select:false), twoFactorEnabled, twoFactorEnrolledAt, twoFactorLastUsedAt, twoFactorDisabledAt, mfaRequiredSince}, MfaRecoveryCode{userId, codeHash, usedAt}, MfaChallenge{userId, tokenHash(unique), attempts, consumedAt, expiresAt, ipAddress} (auth.entities.ts).
  • Endpoints in auth.controller.ts + DTOs in auth.dto.ts (MfaSetupResponse/MfaStatusResponse/login-mfa DTO etc.).
  • Service stubs in auth.service.ts: issueMfaChallenge, loginMfa, setupMfa, verifyMfa, disableMfa, regenerateRecoveryCodes, getMfaStatus.
  • Migration 1794000000001-MfaTotpBackend.ts.
  • buildToken(user, amr) + JwtPayload.amr already wired.

Crypto reuse (mandatory): encryptOAuthToken/decryptOAuthToken (api/src/common/oauth-token-crypto.ts, AES-256-GCM, hard-fails on missing MASTER_ENCRYPTION_KEY). Recovery codes: bcrypt cost 10. Challenge token: sha256(token) stored, raw returned. otplib + qrcode for TOTP.

Node 22 for all build/test (source ~/.nvm/nvm.sh && nvm use 22). API tests: sqlite locally (Postgres-only specs skip locally, enforced in CI).


Group 0 — Migration hygiene (no business logic)

  • Re-timestamp 1794000000001-MfaTotpBackend.ts → run last (e.g. 1796000000005-MfaTotpBackend.ts, class MfaTotpBackend1796000000005) so it never collides with the three other 1794000000001-* migrations or runs before current-dev schema. Update filename + class name + name property.
  • Confirm migrations:run applies cleanly against the local docker DB (the dev-current schema). Verify the users_2fa_secret_present_when_enabled CHECK + partial unused-recovery-codes index + mfa_challenges.token_hash uniqueness exist after.
  • Commit: chore(1245): re-timestamp MFA migration to run last
  • NOTE (not code): checklist §0 production gate — SELECT count(*) FROM users WHERE two_factor_enabled=true on staging/prod must be 0 before this migration runs there, else a pre-step (force-disable + re-enroll email) is required. Flag in the PR; do not attempt against prod.

Group 1 — Crypto + helpers (foundation)

Checklist §1. TOTP secret encrypt/decrypt (store base32 as ciphertext in two_factor_secret_encrypted, .addSelect when reading), recovery-code generation (10× xxxx-xxxx-xxxx, bcrypt-hashed), challenge-token gen (sha256 persisted), re-import otplib generateSecret/generateURI/verify + qrcode. Pure helpers + unit tests (no endpoint wiring).

  • Commit: feat(1245): MFA crypto + recovery-code/challenge helpers

Group 2 — Enrolment service methods

Checklist §2: setupMfa, verifyMfa, disableMfa (403 for MFA_REQUIRED_ROLES when enforcement on), regenerateRecoveryCodes, getMfaStatus (grace-days from mfaRequiredSince ?? createdAt). TDD: happy-path + failure per method (bad TOTP, disable-blocked-for-staff, recovery single-use). Audit verbs (§5) for each.

  • Commits: feat(1245): MFA enrolment service (setup/verify/disable/status/regenerate) (+ test commit if separated)

Group 3 — Two-step login

Checklist §2: issueMfaChallenge (called from login() — already wired), loginMfa (atomic challenge consume UPDATE ... WHERE token_hash=$1 AND consumed_at IS NULL AND expires_at>now() RETURNING *, XOR totp/recovery, attempt-cap burn, buildToken(user,["pwd","otp"|"rc"]), stamp twoFactorLastUsedAt, audit success/failure). TDD incl. expired/consumed challenge, attempt-cap burn, recovery single-use.

  • Commit: feat(1245): two-step MFA login (challenge issue + redeem)

Group 4 — Enforcement guard + rate-limit/lockout

Checklist §3 + §4. New api/src/auth/mfa-enforcement.guard.ts (route-scoped on /ops + /workspace, NOT global APP_GUARD): grace header within window, 403 {error:"mfa_required", enroll_at} past grace, exempt impersonation + non-prod demo + amr already-otp. Config env (MFA_REQUIRED_ROLES, MFA_GRACE_PERIOD_DAYS, MFA_ENFORCE_BLOCK_PATHS) → .env.example. Per-user sliding-window lockout via Redis-backed ThrottlerStorageFactory (NOT in-process); Slack alert on lockout. Guard unit tests (grace header / post-grace 403 / impersonation exemption).

  • Commits: feat(1245): MFA enforcement guard + config, feat(1245): MFA login rate-limit + lockout

Group 5 — E2E bypass gate + cleanup job

Checklist §6 (E2E) + §7. TEST_MFA_BYPASS=true honored only when APP_ENV !== "production" (mirror demo-login gate; no hard-coded bypass tokens). BullMQ scheduled job (stable jobId, active-active) to delete expired mfa_challenges.

  • Commits: test(1245): MFA-bypass login fixtures (non-prod gated), feat(1245): expired-challenge cleanup job

Acceptance (backend slice of the ticket)

  • TOTP enrol/verify/disable/recovery + two-step login implemented; all stubs replaced.
  • Role enforcement (SA/AM/Expert) with grace-period guard.
  • Recovery codes single-use, encrypted, returned once.
  • audit_log on enrol/disable/recovery-use/verify/login-success/login-failure/regenerate.
  • npm test green (Postgres-only specs skip locally, run in CI); migration applies forward.
  • Live smoke on the local stack: setup → verify → login two-step → disable-blocked-for-staff.

Out of scope (this PR)

Frontend (separate PR per 1245-mfa-frontend-implementation.md); admin force-disable; WebAuthn/SMS; key rotation.