feat(2fa): auto-submit six-digit TOTP code fields (BUNYIP-331) #323

Merged
nrupard merged 2 commits from feat/BUNYIP-331-2fa-autosubmit into main 2026-07-02 18:34:54 +02:00
Owner

What

Six-digit TOTP fields now auto-submit their form the moment a complete code is present (typed, pasted, or platform-autofilled), so the user does not click a button to finish 2FA. Implements BUNYIP-331; realizes the governance Authentication UX Standard from GOV-19.

How

  • New shared, opt-in inline snippet OTP_AUTOSUBMIT_JS in the base document head (bunyip-web/src/views/layout.rs), next to the existing theme/toast blocks. One delegated input listener matches input[data-otp-autosubmit]; when the value matches exactly ^[0-9]{6}$ and form.checkValidity() passes, it sets a per-form in-flight flag and calls form.requestSubmit(). CSP already permits the inline block (script-src 'self' 'unsafe-inline', security.rs).
  • Opt in the two dedicated six-digit fields: /login/2fa (auth_pages.rs) and the 2FA setup-confirm field (dashboard.rs).
  • Reorder the /login/2fa trust-device checkbox above the code input so it stays reachable (a complete code now auto-submits before a checkbox below it could be reached on autofill).
  • Update the e2e fillTotpStep helper: setInputValue dispatches a bubbling input event, so the form now auto-submits on fill. The helper waits for the navigation off /login/2fa and only clicks the submit button as a fallback, so it never double-POSTs (which would trip the 2fa_verify rate limit and TOTP replay).

Why these fields only

The combined totp_code sensitive-op forms (change email/password, disable 2FA, delete account, admin reset) are intentionally NOT opted in: they also accept dashed recovery codes and sit last in multi-input forms, so auto-firing there adds no required behavior. The snippet is generic, so any such field can opt in later by adding data-otp-autosubmit. The ^[0-9]{6}$ gate means a recovery code would never auto-submit even if one were opted in.

Recovery-code safety

The exact six-digit regex never matches XXXX-XXXX recovery codes (verified: ABCD-EFGH, 12-3456, partial, and overlong inputs all return false).

Verification

  • just check-container (dockerized): cargo fmt --check, cargo clippy --workspace --all-targets -D warnings, and cargo test --workspace --lib all pass (286 tests green). Maud/Rust edits compile clean.
  • Client snippet syntax-checked with node --check; regex validated against sample codes.
  • The e2e login.ts change is reviewed but not executed locally (e2e node_modules not installed here). It uses standard Playwright APIs; please let CI exercise the auth suite.
  • Governance standard: GOV-19 (and governance PR adding the "Authentication UX Standards" section).
  • mokosh has no native 2FA field (2FA delegated to bunyip via OIDC; psa-systems/mokosh-clients no longer exists), so there is nothing to change there.

#BUNYIP-331

## What Six-digit TOTP fields now auto-submit their form the moment a complete code is present (typed, pasted, or platform-autofilled), so the user does not click a button to finish 2FA. Implements BUNYIP-331; realizes the governance Authentication UX Standard from GOV-19. ## How - New shared, opt-in inline snippet `OTP_AUTOSUBMIT_JS` in the base document head (`bunyip-web/src/views/layout.rs`), next to the existing theme/toast blocks. One delegated `input` listener matches `input[data-otp-autosubmit]`; when the value matches exactly `^[0-9]{6}$` and `form.checkValidity()` passes, it sets a per-form in-flight flag and calls `form.requestSubmit()`. CSP already permits the inline block (`script-src 'self' 'unsafe-inline'`, `security.rs`). - Opt in the two dedicated six-digit fields: `/login/2fa` (`auth_pages.rs`) and the 2FA setup-confirm field (`dashboard.rs`). - Reorder the `/login/2fa` trust-device checkbox above the code input so it stays reachable (a complete code now auto-submits before a checkbox below it could be reached on autofill). - Update the e2e `fillTotpStep` helper: `setInputValue` dispatches a bubbling `input` event, so the form now auto-submits on fill. The helper waits for the navigation off `/login/2fa` and only clicks the submit button as a fallback, so it never double-POSTs (which would trip the `2fa_verify` rate limit and TOTP replay). ## Why these fields only The combined `totp_code` sensitive-op forms (change email/password, disable 2FA, delete account, admin reset) are intentionally NOT opted in: they also accept dashed recovery codes and sit last in multi-input forms, so auto-firing there adds no required behavior. The snippet is generic, so any such field can opt in later by adding `data-otp-autosubmit`. The `^[0-9]{6}$` gate means a recovery code would never auto-submit even if one were opted in. ## Recovery-code safety The exact six-digit regex never matches `XXXX-XXXX` recovery codes (verified: `ABCD-EFGH`, `12-3456`, partial, and overlong inputs all return false). ## Verification - `just check-container` (dockerized): `cargo fmt --check`, `cargo clippy --workspace --all-targets -D warnings`, and `cargo test --workspace --lib` all pass (286 tests green). Maud/Rust edits compile clean. - Client snippet syntax-checked with `node --check`; regex validated against sample codes. - The e2e `login.ts` change is reviewed but not executed locally (e2e `node_modules` not installed here). It uses standard Playwright APIs; please let CI exercise the auth suite. ## Related - Governance standard: GOV-19 (and governance PR adding the "Authentication UX Standards" section). - mokosh has no native 2FA field (2FA delegated to bunyip via OIDC; `psa-systems/mokosh-clients` no longer exists), so there is nothing to change there. #BUNYIP-331
feat(2fa): auto-submit six-digit TOTP code fields
Some checks failed
E2E / Playwright against deployment (pull_request) Failing after 52s
Check / fmt + clippy + build + tests (pull_request) Has been cancelled
bd1634df8e
Add a shared, opt-in client snippet (OTP_AUTOSUBMIT_JS) to the base document head that submits a form the moment a six-digit TOTP field is complete, so the user no longer clicks a button to finish 2FA. A single delegated input listener matches any input[data-otp-autosubmit] and calls form.requestSubmit() when the value matches exactly ^[0-9]{6}$; the exact-six-digit gate keeps dashed recovery codes from ever auto-submitting, and a form.checkValidity() gate plus a per-form in-flight flag prevent half-submits and double submits. The input event also fires on paste and OS/browser OTP autofill, so a one-tap autofill submits too. CSP already allows the inline block (script-src 'unsafe-inline').

Opt the two dedicated six-digit fields in via data-otp-autosubmit: the /login/2fa challenge and the 2FA setup-confirm field. The combined totp_code sensitive-op forms are intentionally left out (they also accept recovery codes and sit last in multi-field forms); the snippet stays generic so they can opt in later.

Reorder the /login/2fa trust-device checkbox above the code input so the choice stays reachable, since a complete code now auto-submits before a checkbox below it could be reached on autofill.

Update the e2e fillTotpStep helper: setInputValue dispatches a bubbling input event, so the form now auto-submits on fill; wait for the navigation off /login/2fa and only click the submit button as a fallback, so the helper never double-POSTs (which would trip the 2fa_verify rate limit and TOTP replay).

#BUNYIP-331

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test(e2e): avoid generating a near-expiry TOTP code in fillTotpStep
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 58s
Check / fmt + clippy + build + tests (pull_request) Successful in 20m51s
Create release / Create release from merged PR (pull_request) Has been skipped
ae82527f70
The server verifies TOTP with skew=0 (BUNYIP-201): only the current 30s step is accepted and there is no last-used-step replay tracking. A code generated in the final moment of a step can expire before the POST lands, which the API rejects as "Invalid verification code" and the login helper then surfaces as a hard failure (the outer retry loop only backs off on rate-limit rejections, not invalid codes). This is latent skew=0 fragility, independent of the auto-submit change, which actually shortens the generate-to-POST latency.

Guard it: if fewer than 5 seconds remain in the current TOTP window, wait for the next step before generating, so the submitted code carries a full step of validity. Also correct the adjacent comment: a duplicate submit risks the per-IP 2fa_verify rate limit, not a replay rejection (there is none).

#BUNYIP-331

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard scheduled this pull request to auto merge when all checks succeed 2026-07-02 18:16:03 +02:00
nrupard scheduled this pull request to auto merge when all checks succeed 2026-07-02 18:18:23 +02:00
nrupard deleted branch feat/BUNYIP-331-2fa-autosubmit 2026-07-02 18:34:55 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!323
No description provided.