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:
- Verify
mfaToken signature and expiry; confirm type === 'mfa_pending'
- Load user from DB; confirm
is_active=1, token_version matches JWT ver
- Try TOTP verification against
totp_secret (window ±1)
- If TOTP fails, try backup code verification
- If both fail: 422 + increment rate-limit counter (5 per 10 min per user)
- 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.
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:mfa_pendingJWT:{ "sub": "<user_id>", "type": "mfa_pending", "ver": <token_version>, "keySalt": "<hex>" }JWT_SECRET. Not accepted byjwtAuthmiddleware (middleware rejects tokens wheretype === 'mfa_pending').202 Accepted:{ "mfaPending": true, "mfaToken": "<signed_jwt>" }Why
keySaltin the pending JWT: The E2EE encryption key is derived frompassword + key_salt. On normal login,key_saltis returned in the 200 response alongside tokens, and the frontend callsinitKey(password, keySalt)immediately. With 2FA, there is no 200 response at step 1 — butkey_saltis needed at step 2 when the frontend finally callsinitKey. Including it in themfa_pendingJWT (which is signed and tamper-proof) avoids a second DB lookup and keeps the server stateless.key_saltis not secret — it is already returned to any authenticated caller via/api/auth/me.New endpoint:
POST /api/auth/totpExchange a TOTP code + pending token for real access/refresh tokens.
Request body:
{ "mfaToken": "<signed_jwt>", "code": "123456" }Handler:
mfaTokensignature and expiry; confirmtype === 'mfa_pending'is_active=1,token_versionmatches JWTvertotp_secret(window ±1)totp_backup_codes, update DBkeySaltin the response (extracted from themfa_pendingJWT — no extra DB read needed)totp_login_okortotp_backup_login_okRate limiting:
totpLimiter(shared with confirm endpoint) — 5 attempts per 10 min per user ID (extracted from the pending JWT).jwtAuthmiddleware guardAdd a check in
jwtAuthmiddleware: if the decoded JWT hastype === 'mfa_pending', reject with 401. This ensures the pending token cannot be used as an access token if intercepted.