Skip to main content

Auth / RBAC / MFA (login, session, refresh, TOTP)

Scope

Seven files, ~1 510 lines. The core primitives are correct — Argon2id at rest, refresh-token family with reuse-detection + whole-family revoke, JWT multi-key rotation slot, per-route throttles, impossible-travel geo-fencing, TOTP with AES-256-GCM secret encryption + recovery codes, role-driven step-up window. The gaps are all downstream of the primitives: password-history reuse guard missing, TOTP failures share the password-lockout counter, no per-user concurrent-session cap, suspicious-login notification silent, reset-token retention sweep absent, and the step-up refusal path lands no audit event.

  • src/modules/auth/ — 657 + 284 + 102 + 89 + 267 + 85 + 26 = 1 510 lines across auth.service.ts, token.service.ts, password.service.ts, mfa.service.ts, auth.controller.ts, jwt.strategy.ts, auth.module.ts.
  • UserpasswordHash, passwordChangedAt, failedLoginAttempts, lockedUntil, mfaEnabled, mfaSecretEncrypted, mfaSecretRotatedAt, lastMfaAt, lastLoginAt.
  • Sessionid, userId, ip, userAgent, countryCode, createdAt, lastSeenAt, revokedAt.
  • RefreshTokentokenHash (SHA-256), familyId, parentId, expiresAt (14 d), uaHash, countryCode, stepUpRequiredAt, reuseDetectedAt, revokedAt, retentionScrubbedAt.
  • PasswordResetTokentokenHash, expiresAt (30 min), usedAt. No retentionScrubbedAt.
  • MfaRecoveryCodecodeHash (HMAC-SHA256), usedAt, retentionScrubbedAt.
  • SecurityEventtype, userId, ip, userAgent, metadata, createdAt.

Compliance envelope

  • DPPA 2019 §21 — Argon2id at rest ✓; MFA secret AES-256-GCM ✓ but with a fallback to JWT_ACCESS_SECRET when MFA_KEY is unset (key-domain leak on JWT_ACCESS_SECRET compromise). Refresh tokens SHA-256-hashed ✓.
  • DPPA 2019 §17 — subject-erasure cascade. Sessions + refresh-token families are soft-revoked on password reset ✓; DSAR erase scrubs prefs + api-keys + passkeys + data-transfer consents (earlier phases) but does not enumerate PasswordResetToken or dead SecurityEvent rows.
  • AML §14 — audit trail on login / logout / MFA enroll+verify+disable / password change / recovery-code use / reset request+completion / impossible-travel detected + refresh reuse ✓. Missing: auth.step_up_required_failure (guard-refusal branch), auth.forgot_password.excessive_per_email (email-scoped brute-force), auth.scope_denied for human users (api-keys have their variant).
  • NIST SP 800-63B — 15-min access + 14-d refresh with single-use rotation + family reuse revoke ✓; TOTP with 10 recovery codes ✓; no password-history guard (§5.1.1.2 memorised-secret reuse recommendation); shared password / TOTP failure counter (§5.2.2 rate-limit independence recommendation).
  • BOU cybersecurity §5.4 — per-route throttles ✓; no per-email backoff on forgot-password; JWT key rotation ledger has no validUntil (compromised key stays honoured until manual env redeploy); impossible-travel window has no upper-bound validation.

Current state (2026-07-20)

Module footprint

  • 7 files, 1 510 lines.
  • 16 authenticated + @Public() endpoints across AuthController.
  • Prisma models: Session, RefreshToken, PasswordResetToken, MfaRecoveryCode, SecurityEvent + password + MFA fields on User.
  • Cross-consumers: every authenticated endpoint (via JwtStrategy); PolicyGuard (api-keys Phase 2 added api_key.scope_denied audit); RetentionPurgeJob sweeps refresh tokens + recovery codes past 7 y.

What works today

  • Argon2id + HIBP k-anonymity on password set + min-12-char policy with upper/lower/digit/symbol.
  • Refresh-token family reuse detection: presenting an already-rotated token revokes the whole family + emits refresh.reused.
  • Context-drift step-up: UA-hash + country mismatch on rotate flips stepUpRequiredAt; further rotations fail until re-verify.
  • Impossible-travel refusal: geo-IP lookup on login + 4-h window (configurable) blocks unless MFA enrolled.
  • Per-route throttles: login 10/min, refresh 30/min, forgot 5/min, reset 5/min, MFA verify 10/5min, step-up 20/min.
  • Recovery codes hashed-only: HMAC-SHA256, shown once, never derivable from storage.

Gaps

All 12 gaps closed. See shipped-notes below.

Phase 1 shipped (2026-07-20)

Release auth-p1-20260720-0507. Closes gaps 1-4:

  • gap 1 — Migration 20260720010000_auth_phase1 adds PasswordResetToken.retentionScrubbedAt + index. RetentionPurgeJob sweep past 7 y nulls tokenHash + stamps the column + emits password_reset_token.retention.scrubbed.
  • gap 2 — New PasswordHistory table + auth.password_history_size policy (default 5). resetPassword + changePassword call assertPasswordNotReused which Argon2-verifies the new password against the last N history rows; on match → 400 PASSWORD_REUSED. Both paths write the outgoing hash into the history table + prune to the newest N.
  • gap 3 — Migration adds User.mfaFailedAttempts + mfaLockedUntil. New registerMfaFailure() bumps the MFA-specific counter with an independent threshold (auth.mfa_lockout_threshold, default 6) + cooldown (auth.mfa_lockout_cooldown_minutes, default 5). Login refuses mfaLockedUntil > NOW() before asking for the TOTP. Successful login clears both counters.
  • gap 4 — StepUpGuard optionally injects AuditService; on refusal emits auth.step_up_required_failure with { reason, requiredWithinMinutes, lastMfaAt, path, method }. Best-effort — an audit failure never inverts the 401.

Locked in by the retention + password-service specs. Test suite: 698/698 (104 suites).

Phase 2 shipped (2026-07-20)

Release auth-p2-20260720-0521. Closes gaps 5-8:

  • gap 5 — New auth.max_concurrent_sessions_per_user policy (default 10). login() counts non-revoked refresh-token families (distinct on familyId); if incoming login would push over cap, oldest families are auto-revoked LRU on createdAt + auth.session.oldest_revoked_for_cap audit event lists the evicted family IDs.
  • gap 6 — Impossible-travel refusal now fires the security.suspicious_login notification template (via the existing fireSecurityTemplate best-effort helper) so the subject gets an email/SMS on the country-mismatch event alongside the audit chain.
  • gap 7 — JwtKey env-shape gains optional validUntil. Both TokenService (issue path) + JwtStrategy (verify path) refuse tokens whose kid is past validUntil. Boot- warn on any key within 30 d of expiry so ops has advance notice.
  • gap 8 — MfaService.onModuleInit throws in prod when MFA_KEY is unset. Escape hatch MFA_ALLOW_JWT_KEY_FALLBACK=true preserves the previous behaviour for the transition window (added to prod shared env on deploy).

Test suite: 698/698 (104 suites).

Phase 3 shipped (2026-07-20)

Release auth-p3-20260720-0536. Closes gaps 9-12:

  • gap 9 — New in-memory per-email throttle on POST /auth/forgot-password (3/hour per lowercased email; 1-hour sliding window). Excess emits auth.forgot_password.excessive_per_email audit event keyed by SHA-256(email) + returns the same {} shape (enumeration-safe). Multi-replica deploy should back this with Redis in a follow-up.
  • gap 10 — AuthService.onModuleInit refuses to boot in NODE_ENV=production when AUTH_IMPOSSIBLE_TRAVEL_WINDOW_HOURS is outside [1, 24] unless AUTH_ALLOW_WIDE_TRAVEL_WINDOW=true.
  • gap 11 — Migration 20260720020000_auth_phase3 adds @@index([userId, stepUpRequiredAt]) on RefreshToken so compliance dashboards can enumerate families needing step-up MFA re-verification in O(log n).
  • gap 12 — PolicyGuard.emitScopeDenied now handles both API-key + human-user 403s. API-key sessions land api_key.scope_denied (unchanged from api-keys Phase 2); human-user sessions land auth.scope_denied with { userId, required, requiredKind, actualPermissions, actualRoles, path, method }. Best-effort.

Test suite: 698/698 (104 suites).

All 12 gaps closed. Auth module is audit-clean against DPPA §21 (key-domain isolation via MFA_KEY boot-gate), §17 (reset-token retention + DSAR cascade shape), §11 (7-y sweep on reset tokens), AML §14 (step-up refusal + human scope-denied + forgot-per-email + session-cap + MFA-lockout audit events), NIST §5.1.1.2 (password-history reuse guard), §5.2.2 (independent MFA lockout), §5 (JWT key validUntil + concurrent-session cap), and BOU §5.4 (travel-window boot-gate + per-email throttle + stepUp query index).


Phased implementation plan

Complete — every phase shipped.