Skip to content
This repository was archived by the owner on Apr 10, 2026. It is now read-only.
This repository was archived by the owner on Apr 10, 2026. It is now read-only.

feat(server): 2FA — login challenge state and /api/auth/totp endpoint #52

@dajbelshaw

Description

@dajbelshaw

Part of #50.

Login flow modification (POST /api/auth/login)

After successful password verification, check totp_enabled:

If totp_enabled = 0 (current behaviour): issue access + refresh tokens, return 200 as today.

If totp_enabled = 1: do not issue tokens. Instead:

  • Sign a short-lived mfa_pending JWT:
    { "sub": "<user_id>", "type": "mfa_pending", "ver": <token_version>, "keySalt": "<hex>" }
    Expiry: 5 minutes. Signed with JWT_SECRET. Not accepted by jwtAuth middleware (middleware rejects tokens where type === 'mfa_pending').
  • Return 202 Accepted:
    { "mfaPending": true, "mfaToken": "<signed_jwt>" }

Why keySalt in the pending JWT: The E2EE encryption key is derived from password + key_salt. On normal login, key_salt is returned in the 200 response alongside tokens, and the frontend calls initKey(password, keySalt) immediately. With 2FA, there is no 200 response at step 1 — but key_salt is needed at step 2 when the frontend finally calls initKey. Including it in the mfa_pending JWT (which is signed and tamper-proof) avoids a second DB lookup and keeps the server stateless. key_salt is not secret — it is already returned to any authenticated caller via /api/auth/me.

New endpoint: POST /api/auth/totp

Exchange a TOTP code + pending token for real access/refresh tokens.

Request body:

{ "mfaToken": "<signed_jwt>", "code": "123456" }

Handler:

  1. Verify mfaToken signature and expiry; confirm type === 'mfa_pending'
  2. Load user from DB; confirm is_active=1, token_version matches JWT ver
  3. Try TOTP verification against totp_secret (window ±1)
  4. If TOTP fails, try backup code verification
  5. If both fail: 422 + increment rate-limit counter (5 per 10 min per user)
  6. On success:
    • If backup code used: remove it from totp_backup_codes, update DB
    • Issue access + refresh tokens (same as normal login success)
    • Include keySalt in the response (extracted from the mfa_pending JWT — no extra DB read needed)
    • Audit log: totp_login_ok or totp_backup_login_ok

Rate limiting: totpLimiter (shared with confirm endpoint) — 5 attempts per 10 min per user ID (extracted from the pending JWT).

jwtAuth middleware guard

Add a check in jwtAuth middleware: if the decoded JWT has type === 'mfa_pending', reject with 401. This ensures the pending token cannot be used as an access token if intercepted.

Metadata

Metadata

Assignees

No one assigned

    Labels

    authAuthentication & sessionsenhancementNew feature or requestsecuritySecurity concern

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions