Skip to content

fix(frontend): tell users to sign in again or wait after too many MFA codes (#872) - #880

Merged
alex-dembele merged 5 commits into
masterfrom
fix/872-mfa-challenge-refusals
Oct 2, 2026
Merged

alex-dembele merged 5 commits into
masterfrom
fix/872-mfa-challenge-refusals

Conversation

@alex-dembele

@alex-dembele alex-dembele commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Closes #872

Since #689 (PR #875) the MFA challenge can refuse a code in ways a new code cannot fix, but the sign-in screen said "incorrect code" every time. Worse, after five wrong codes the next attempt came back 401 TOKEN_REVOKED, and the axios interceptor treated it as a lost session: it tried to refresh an unrelated session, then reloaded /login. The user landed on an empty password form with no explanation.

What changes

Interceptor (lib/api.ts). A request that carries its own MFA token keeps its 401s: authService marks it ownCredential, a typed axios config flag that is never sent to the server. That covers the challenge and mandated enrolment, whose 401s are the screen's to explain. /auth/mfa/setup and /verify called from Settings run on the session, so they keep the refresh-and-redirect behaviour, as does every other request.

Classifying the refusal (features/auth/challengeRefusal.ts, pure and typed):

  • MFA_CHALLENGE_EXHAUSTED and TOKEN_REVOKED mean the sign-in used its codes;
  • TOKEN_EXPIRED, TOKEN_INVALID, UNAUTHORIZED or any other 401 mean the step expired;
  • 429 MFA_LOCKED means wait. The duration comes from retry_after, then Retry-After, rounded up to whole minutes;
  • any other 429 (the per-IP limit) means a wait of unknown length;
  • everything else is still "incorrect code".

Screen (AuthScreen.tsx):

  • Restart: the screen goes back to the password form, with the address and password still filled in, and the banner says why. On the rare registration path, it lands on the login form with the same banner.
  • Locked: "try again in N minutes". The code field and the button are disabled until the lock ends, then focus returns to the field. "Back to sign in" stays usable.

The copy is FR/EN in authStrings.ts, typed by AuthCopy, where the rest of the auth copy lives. That differs from criterion 4 of the issue, which named fr.json/en.json: I wrote that criterion without checking how this module handles its copy.

Mandated enrolment (criterion 6, added to the issue). An expired 15-minute enrolment token, at setup or at verify, returns to the password form with "this sign-in step has expired" instead of "incorrect code", or instead of the old silent reload of /login.

Contrast: axe flagged two serious failures on these screens.

  • Error banner: its text measured 4.46:1. Lowering the tint from 10% to 7% gives 4.68:1 light and 5.20:1 dark.
  • Footer year: at 3.35:1 because of opacity-70, which I removed.

Verification

  • npx tsc -b --noEmit: exit 0.
  • npx vite build: built.
  • npx vitest run: 103 files, 965 tests passed.
  • npx eslint --max-warnings=0 on every touched file: clean.
  • Tests:
    • new: challengeRefusal.test.ts (7), mfaChallengeRefusals.test.tsx (9), plus 5 interceptor cases in api.test.ts;
    • against the old AuthScreen.tsx, 8 of the 9 screen tests fail. The one that passes covers "incorrect code", whose behaviour is unchanged;
    • against the old interceptor, the 4 challenge cases fail.

Live: this branch's Vite build against the #689 backend (local Postgres and Redis), signing in as an admin enrolled in TOTP:

FR 1280x800  codes 1-4: 400 → stays, "Code incorrect…"
             code 5:   401 → password form, filled, "Trop de codes erronés pour cette connexion. Reconnectez-vous avec votre mot de passe."
             new sign-in, codes 1-4: 400 · code 5: 429 Retry-After=900 → "… réessayez dans 15 minutes.", field and button disabled
EN 414x600   locked: 429 Retry-After=829 → "… try again in 14 minutes.", no horizontal scroll, Verify button in view
             exhausted: 401 → password form, "Too many wrong codes for this sign-in. Sign in again with your password."
axe (light and dark, 414x600, after the contrast fix): no serious or critical; two pre-existing moderate landmark findings
console: 21 entries, all the expected 400/401/429 network lines, no script error

Between the EN lock and the EN exhausted check, I cleared the lock keys in the throwaway Redis instead of waiting fifteen minutes.

Criteria 6 and 7, live against master plus #888 (master does not compile without it, see #886). In both cases a valid code was entered, so only the expiry explains the refusal:

challenge opened 12:18:41, valid code at 12:24:55 → 401 TOKEN_EXPIRED → password form, filled,
  "Cette étape de connexion a expiré. Reconnectez-vous avec votre mot de passe." (no reload)
enrolment opened 12:18:43, valid code at 12:34:37 → 401 TOKEN_EXPIRED → same, address kept

Signing in again after the expired enrolment then hit a separate backend defect: setup answered 400 forever. That is #889, fixed in #892, and verified live to end on a session.

Tests added for this part: authServiceOwnCredential.test.ts (3), 3 enrolment cases in mfaChallengeRefusals.test.tsx, and the interceptor tests rewritten around the flag, including a Settings-on-session case that must still refresh. Against the previous screen, the 2 expiry tests fail.

Merge order

#689 is already on master (via #871). npm run lint:ceiling fails on this branch exactly as it does on master: no-irregular-whitespace, no-raw-colors and react-hooks/refs, all in files this PR does not touch. #891 fixes them. Merge #891, then bring master into this branch.

Not done

The challenge is authenticated by the short-lived MFA token, not by the
session. When that token was revoked after five wrong codes, or had
expired, the response interceptor treated the 401 as a lost session: it
tried to refresh an unrelated session, then reloaded /login. The user
landed on an empty password form with no word about what happened.

Requests to /auth/mfa/challenge now pass their errors straight to the
caller. Every other request keeps the refresh and redirect behaviour.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
… codes (#872)

Since #689 the challenge refuses a code in four ways, and the screen
said "incorrect code" for all of them. Someone whose sign-in had used
its five codes kept typing new ones into a token the server had closed.

A spent, revoked or expired challenge now returns to the password form,
with the address and password still filled in and a banner saying why.
A locked account is told how many minutes to wait; the code field and
the button stay disabled until then, and focus comes back to the field
when the lock ends. The per-address limit, which gives no duration,
reads as "try again in a few minutes". A wrong code is unchanged.

The copy lives in authStrings.ts with the rest of the auth screens, in
French and English.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
…st (#872)

axe flagged two serious colour-contrast failures on the sign-in and MFA
screens. The error banner's 12.5px text measured 4.46:1 on the light
theme, and this change shows that banner in more situations. Its tint
goes from 10% to 7%, which measures 4.68:1 light and 5.20:1 dark. The
footer year was dimmed with opacity-70 to 3.35:1; it now takes the
colour of the rest of the line, which passes.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
…h a reason (#872)

Mandated enrolment runs on a 15-minute MFA_ENROLLMENT token. Once it
expired, /auth/mfa/verify answered 401, the response interceptor took it
for a lost session, and /login reloaded with no word of explanation.

The exemption added for the challenge was keyed on its URL, which could
not cover enrolment: /auth/mfa/setup and /verify also run from Settings
on a real session, where an expired access token must still refresh. So
the exemption now follows the credential instead. authService marks a
request ownCredential when it sends an MFA token, and the interceptor
leaves only those alone.

On the enrolment screen, a refusal no code can fix returns to the
password form with "this sign-in step has expired" instead of
"incorrect code", whether it happens at setup or at verify.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
@alex-dembele
alex-dembele merged commit 99f86ec into master Oct 2, 2026
10 of 25 checks passed
@alex-dembele
alex-dembele deleted the fix/872-mfa-challenge-refusals branch October 2, 2026 13:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:frontend React, /src priority:P1-high Blocks a milestone tier:0-trust Trust: security, isolation, evidence integrity type:bug Something is broken

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant