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 acrossauth.service.ts,token.service.ts,password.service.ts,mfa.service.ts,auth.controller.ts,jwt.strategy.ts,auth.module.ts.User—passwordHash,passwordChangedAt,failedLoginAttempts,lockedUntil,mfaEnabled,mfaSecretEncrypted,mfaSecretRotatedAt,lastMfaAt,lastLoginAt.Session—id,userId,ip,userAgent,countryCode,createdAt,lastSeenAt,revokedAt.RefreshToken—tokenHash(SHA-256),familyId,parentId,expiresAt(14 d),uaHash,countryCode,stepUpRequiredAt,reuseDetectedAt,revokedAt,retentionScrubbedAt.PasswordResetToken—tokenHash,expiresAt(30 min),usedAt. NoretentionScrubbedAt.MfaRecoveryCode—codeHash(HMAC-SHA256),usedAt,retentionScrubbedAt.SecurityEvent—type,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_SECRETwhenMFA_KEYis unset (key-domain leak onJWT_ACCESS_SECRETcompromise). 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
PasswordResetTokenor deadSecurityEventrows. - 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_deniedfor 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 acrossAuthController. - Prisma models:
Session,RefreshToken,PasswordResetToken,MfaRecoveryCode,SecurityEvent+ password + MFA fields onUser. - Cross-consumers: every authenticated endpoint (via
JwtStrategy);PolicyGuard(api-keys Phase 2 addedapi_key.scope_deniedaudit);RetentionPurgeJobsweeps 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.
Release auth-p1-20260720-0507. Closes gaps 1-4:
- gap 1 — Migration
20260720010000_auth_phase1addsPasswordResetToken.retentionScrubbedAt+ index.RetentionPurgeJobsweep past 7 y nullstokenHash+ stamps the column + emitspassword_reset_token.retention.scrubbed. - gap 2 — New
PasswordHistorytable +auth.password_history_sizepolicy (default 5).resetPassword+changePasswordcallassertPasswordNotReusedwhich Argon2-verifies the new password against the last N history rows; on match → 400PASSWORD_REUSED. Both paths write the outgoing hash into the history table + prune to the newest N. - gap 3 — Migration adds
User.mfaFailedAttempts+mfaLockedUntil. NewregisterMfaFailure()bumps the MFA-specific counter with an independent threshold (auth.mfa_lockout_threshold, default 6) + cooldown (auth.mfa_lockout_cooldown_minutes, default 5). Login refusesmfaLockedUntil > NOW()before asking for the TOTP. Successful login clears both counters. - gap 4 —
StepUpGuardoptionally injectsAuditService; on refusal emitsauth.step_up_required_failurewith{ 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).
Release auth-p2-20260720-0521. Closes gaps 5-8:
- gap 5 — New
auth.max_concurrent_sessions_per_userpolicy (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 oncreatedAt+auth.session.oldest_revoked_for_capaudit event lists the evicted family IDs. - gap 6 — Impossible-travel refusal now fires the
security.suspicious_loginnotification template (via the existingfireSecurityTemplatebest-effort helper) so the subject gets an email/SMS on the country-mismatch event alongside the audit chain. - gap 7 —
JwtKeyenv-shape gains optionalvalidUntil. BothTokenService(issue path) +JwtStrategy(verify path) refuse tokens whose kid is pastvalidUntil. Boot- warn on any key within 30 d of expiry so ops has advance notice. - gap 8 —
MfaService.onModuleInitthrows in prod whenMFA_KEYis unset. Escape hatchMFA_ALLOW_JWT_KEY_FALLBACK=truepreserves the previous behaviour for the transition window (added to prod shared env on deploy).
Test suite: 698/698 (104 suites).
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 emitsauth.forgot_password.excessive_per_emailaudit event keyed bySHA-256(email)+ returns the same{}shape (enumeration-safe). Multi-replica deploy should back this with Redis in a follow-up. - gap 10 —
AuthService.onModuleInitrefuses to boot inNODE_ENV=productionwhenAUTH_IMPOSSIBLE_TRAVEL_WINDOW_HOURSis outside[1, 24]unlessAUTH_ALLOW_WIDE_TRAVEL_WINDOW=true. - gap 11 — Migration
20260720020000_auth_phase3adds@@index([userId, stepUpRequiredAt])onRefreshTokenso compliance dashboards can enumerate families needing step-up MFA re-verification in O(log n). - gap 12 —
PolicyGuard.emitScopeDeniednow handles both API-key + human-user 403s. API-key sessions landapi_key.scope_denied(unchanged from api-keys Phase 2); human-user sessions landauth.scope_deniedwith{ 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.