Skip to content

fix(frontend): the MFA code screen does not tell users to sign in again or wait after too many wrong codes #872

Closed

Description

@alex-dembele

Problem

When signing in with two-factor authentication, someone who mistypes their code too often gets stuck. The screen keeps saying "invalid code" while the server has already stopped listening.

Since #689 the challenge endpoint refuses in two new ways:

  • 401 {"code":"MFA_CHALLENGE_EXHAUSTED"}: this sign-in attempt used its 5 codes. The token is revoked, and any further code on it answers 401 TOKEN_REVOKED. The only way forward is to enter the password again.
  • 429 {"code":"MFA_LOCKED","retry_after":<seconds>} with a Retry-After header: the account failed 10 times in 15 minutes and refuses codes for 15 minutes.

AuthScreen.tsx maps every challenge error to copy.mfaInvalid (the catch around challengeMFA). So the user keeps typing codes into a dead token, or into a locked account, with no hint that they should start over or wait.

Acceptance criteria

  1. On MFA_CHALLENGE_EXHAUSTED, or on TOKEN_REVOKED from the challenge, the screen drops the MFA token, returns to the password step, and says why: FR « Trop de codes erronés pour cette connexion. Reconnectez-vous avec votre mot de passe. » / EN "Too many wrong codes for this sign-in. Sign in again with your password."

  2. On MFA_LOCKED, the screen says how long to wait, from retry_after (rounded up to whole minutes), in FR and EN, and disables the code field until then.

  3. A wrong code that is neither of these still shows the existing "invalid code" message.

  4. The strings live in fr.json and en.json. There are no inline literals, and the types have no any.

  5. Tests in features/auth/__tests__/ cover each of the three responses.

  6. (Added 2026-10-02 at the owner's request.) Mandated enrolment has the same defect. An expired enrolment token (MFAEnrollmentTTL, 15 min) made /auth/mfa/verify reload /login with no message. It now returns to the password with the "step expired" reason. The same endpoints called from Settings, with a session, keep the refresh-and-redirect behaviour.

  7. (Added 2026-10-02.) The expired challenge (criterion 1, TOKEN_EXPIRED) is checked live, after the real 5 minutes, not only in tests.

Definition of Done

  • npx tsc -b --noEmit, npm run build and npx vitest run green, with output pasted here.
  • Live pass against a local backend with an enrolled account: 5 wrong codes, then 10 across sign-ins. Screenshots of both states, FR and EN.
  • Progress comment in the CLAUDE.md format; PR with Closes this issue.

Follows #689 (backend limits). The owner decided on 2026-10-02 that the copy ships separately.

Activity

  1. added this to the trust-v1 milestone on Oct 2, 2026
  2. added
    area:securityCybersecurity and threat intelligence
    type:bugSomething is broken
    status:readyMeets the ready definition
    tier:0-trustTrust: security, isolation, evidence integrity
    and removed
    status:readyMeets the ready definition
    on Oct 2, 2026
  3. alex-dembele commented on Oct 2, 2026

    @alex-dembele
    MemberAuthor

    frontend — 2026-10-02

    Done

    • frontend/src/lib/api.ts: requests to /auth/mfa/challenge no longer trigger a session refresh or the /login reload on a 401. That reload was wiping the explanation after the 6th code (TOKEN_REVOKED).
    • frontend/src/features/auth/challengeRefusal.ts: a pure classifier with three outcomes: invalid, restart (exhausted or expired), or locked (with a duration, or null for the per-IP limit).
    • frontend/src/features/auth/AuthScreen.tsx:
      • restart: back to the password form, still filled in, with the reason;
      • locked: "N minutes", with the field and the button disabled until the lock ends, then focus returns to the field.
    • frontend/src/features/auth/authStrings.ts: FR/EN copy (mfaExhausted, mfaExpired, mfaLocked, mfaLockedUnknown).
    • frontend/src/features/auth/fields.tsx, AuthLayout.tsx: two serious axe contrast failures fixed (error banner 4.46 → 4.68:1, footer year 3.35:1).
    • PR fix(frontend): tell users to sign in again or wait after too many MFA codes (#872) #880.

    Verified

    • npx tsc -b --noEmit exit 0; npx vite build OK; npx vitest run: 103 files, 965 tests passed; eslint --max-warnings=0 clean on the touched files.
    • The new screen tests fail against the old screen (8 of 9; the 9th covers "incorrect code", which is unchanged), and the 4 interceptor cases fail against the old interceptor.
    • Live, against the security(auth): MFA challenge codes can be guessed without limit — no attempt counter, no rate limit #689 backend:
      • FR at 1280×800: code 5 → password form with the reason; then a lock at 429 with Retry-After=900 shows "15 minutes", with the field disabled.
      • EN at 414×600: lock shows "14 minutes" (829 s rounded up), exhausted returns to the password form. No horizontal scroll, button in view.
      • axe, light and dark: 0 serious or critical. Console: only the expected 400/401/429 network lines.

    Criteria

    • 1 ✅ · 2 ✅ · 3 ✅ · 5 ✅
    • 4 ⚠️: FR/EN are present and typed, with no any, but in authStrings.ts, the auth module's convention, not fr.json/en.json as the criterion says. I wrote the criterion without checking. Tell me if you want the module moved.

    Next: owner review of #880, to merge after #875.

    Blocked on: #875 for the codes to appear in production. Nothing for the review.

    Not done: the expired-challenge case was only tested, not checked live; expired enrolment tokens (setup/verify) still reload /login with no message, which is outside this issue.

  4. alex-dembele commented on Oct 2, 2026

    @alex-dembele
    MemberAuthor

    frontend — 2026-10-02 (criteria 6 and 7)

    Done

    • frontend/src/lib/api.ts and frontend/src/features/auth/authService.ts: the interceptor exemption now follows the credential, through a typed ownCredential config flag set when an MFA token is sent, instead of the challenge URL. Mandated enrolment is covered too, and Settings (which runs on the session) keeps its refresh.
    • frontend/src/features/auth/AuthScreen.tsx: an expired enrolment token, at setup or at verify, returns to the password form with mfaExpired.
    • Commit 8b0e152b on PR fix(frontend): tell users to sign in again or wait after too many MFA codes (#872) #880.

    Verified

    Criteria: 6 ✅ · 7 ✅ (1 to 5 unchanged, see the earlier comment)

    Next: merge #891, bring master into this branch, then owner review of #880.

    Blocked on: #891 for a green lint gate.

    Found along the way, now separate: #886 (master does not compile, P0, PR #888) and #889 (an unfinished enrolment locked the account out for good, PR #892).

  5. added a commit that references this issue on Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions