Files
sencho/frontend/src/lib/mfa.ts
T
Anso 4722028904 feat(mfa): UX hardening — auto-submit, paste tolerance, low-codes warning, dev-mode diagnostics (#620)
* feat(mfa): auto-submit 6-digit TOTPs and normalize pasted backup codes

Match the UX every major MFA prompt has (GitHub, GitLab, 1Password): the
challenge screen and every code-entry dialog now submit automatically once
the sixth TOTP digit lands, and the backup-code input accepts pastes with
smart-dashes, trailing whitespace, or mixed case without silently
truncating the value. Also caps the backup-code input at the correct
11 characters (10 plus a single separator) instead of 12.

Shared normalization helpers live in frontend/src/lib/mfa.ts so the
challenge and the three account-settings dialogs stay in lockstep.

* feat(mfa): warn users when backup codes run low

The Account & Security card silently showed a dim count of backup codes
remaining, which meant users could drift toward zero without noticing
until their phone was already lost. The card now surfaces a warning tone
with an alert icon when 1 or 2 codes remain, and swaps to a dedicated
destructive warning card with a "Regenerate now" action when the user
has used every code.

* feat(mfa): gate diagnostic logs behind developer mode

Reuses the existing isDebugEnabled() gate so operators investigating a
2FA support ticket can flip Developer Mode on to get per-branch
diagnostics (login path taken, replay check outcome, failure counter
after a verify, replay-table purge counts), and flip it back off when
they are done. Standard lifecycle logs stay on by default: enrolment
completed, 2FA disabled, backup codes regenerated, admin reset, SSO
bypass toggled, lockout engaged. Nothing that could reveal a TOTP code,
base32 secret, backup-code cleartext, or partial-auth JWT is ever
logged.

* test(mfa): cover drift, invalid formats, lockout recovery, and paste normalization

Backend: a TOTP generated for a window that has already slid out is
rejected, malformed backup codes (too short, non-alphanumeric, 11-char
alphanumeric that matches no hash) all increment failed_attempts, a
successful verify clears a below-threshold failure streak, a successful
verify after locked_until has passed clears the lockout, a second
enroll/start overwrites the prior pending secret, and the backup-code
normalizer treats en-dash/em-dash/figure-dash with stray whitespace the
same as the canonical form.

E2E: low-backup-codes warning renders in the warning tone and the
exhausted-codes state flips to the dedicated warning card, a 6-digit
TOTP auto-submits without a button click, and a backup code pasted
without the separator still signs in.

* docs(mfa): auto-submit, paste guidance, and expanded troubleshooting

Document that the challenge screen submits automatically on the sixth
digit, that backup codes accept the separator and any case, and that
the Account & Security card nudges at low code counts. Expands the
troubleshooting section with entries for lost or exhausted backup codes
and adds a short note to the admin guide about surfacing auth
diagnostics via Developer Mode.
2026-04-15 19:51:44 -04:00

46 lines
2.1 KiB
TypeScript

/**
* MFA input helpers shared by the challenge screen and the MFA dialogs.
*
* The server is authoritative: it strips whitespace on TOTPs and calls
* `MfaService.normalizeBackupCode` to accept backup codes with or without a
* separator. We still normalize on the client so the input shows the user a
* clean value, enforces a correct length cap, and stays consistent when a
* code is pasted from a password manager (which may include smart-dashes,
* line breaks, or trailing whitespace).
*/
/** Length of a raw backup code before any display formatting. */
export const BACKUP_CODE_RAW_LENGTH = 10;
/** Length of a backup code as displayed to the user: `ABCDE-FGHIJ`. */
export const BACKUP_CODE_DISPLAY_LENGTH = BACKUP_CODE_RAW_LENGTH + 1;
/** Length of the 6-digit TOTP used by every authenticator app we support. */
export const TOTP_LENGTH = 6;
/**
* Normalize an incoming TOTP value to exactly the characters the server
* will accept: digits only, capped at 6. Keeps typing fluid when a password
* manager injects an extra space or the user pastes ` 123 456 `.
*/
export function normalizeTotpInput(raw: string): string {
return (raw || '').replace(/\D+/g, '').slice(0, TOTP_LENGTH);
}
/**
* Normalize an incoming backup code and produce a display-formatted value.
*
* - Uppercases everything (backup codes are printed in uppercase).
* - Strips every character that is not `A-Z` or `0-9`, which drops spaces,
* line breaks, and any dash variant (`-`, en-dash, em-dash, figure-dash).
* - Caps at 10 raw characters so `maxLength` never truncates a pasted value
* mid-character.
* - Reintroduces a single `-` after the 5th character so the input mirrors
* the canonical `ABCDE-FGHIJ` layout users see in the enrolment dialog.
*/
export function normalizeBackupCodeInput(raw: string): { display: string; raw: string } {
const stripped = (raw || '').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, BACKUP_CODE_RAW_LENGTH);
if (stripped.length <= 5) return { display: stripped, raw: stripped };
return { display: `${stripped.slice(0, 5)}-${stripped.slice(5)}`, raw: stripped };
}