VAPP-13: 2FA enrollment UI: TOTP and WebAuthn passkeys #11

Merged
David merged 2 commits from feat/2fa-enrollment-ui-vapp-13 into main 2026-06-07 00:24:55 +02:00
Owner

VAPP-13: 2FA enrollment UI: TOTP and WebAuthn passkeys

Adds a Security card to Settings that enrolls/confirms/disables TOTP and registers WebAuthn passkeys. Every call proxies vervain-server's session-gated 2FA routes through a server fn that forwards the vervain_session token as the mc_session cookie the server expects (same pattern as the existing login proxy), because the browser does not hold that cookie on the vervain-server origin.

What changed

  • Server fns (src/server_fns/mod.rs): get_2fa_status reads the sentinel-replaced userinfo frame from the control-channel connect burst (otpsecret -> 1 when set, otphkeys -> passkey count, so no secret crosses the wire); totp_start / totp_confirm / totp_disable proxy POST /2fa/totp/*; webauthn_register_start / webauthn_register_finish proxy POST /webauthn/register/*. Shared post_authed attaches the forwarded cookie; ok_or_body forwards the server's own error bodies (e.g. "code did not verify") to inline UI errors, and a 401 maps to the auth marker so the existing sign-in banner appears.
  • QR (AC: no external JS or image service): rendered server-side. render_totp_qr_svg encodes the otpauth URI with the pure-Rust qrcode crate (default features off, so neither the image nor svg renderer crate is pulled) and builds a standalone inline SVG by hand from the module matrix.
  • WebAuthn ceremony (src/webauthn.rs): runs in the wasm client through a small JS shim inlined by wasm-bindgen (bundled into the wasm, no external script). It base64url-decodes the webauthn-rs challenge into the ArrayBuffers navigator.credentials.create expects, runs the ceremony, and re-encodes the attestation into the RegisterPublicKeyCredential JSON register/finish deserializes. A NotAllowedError (cancel/timeout) and other browser errors surface inline. web-sys was avoided because its WebAuthn dictionaries are gated behind the web_sys_unstable_apis cfg, which would change the CI build invocation.
  • UI (src/components/pages/settings.rs): SecurityCard drives all four flows with busy/error state, a two-click confirm gate for disabling (in place of a JS dialog), the QR + manual-secret + code-confirm enroll panel, and a passkey count that refreshes after each registration. New models TwoFactorStatus and TotpEnrollment.

Acceptance criteria

  • TOTP enroll panel: QR (server-rendered SVG) + manual secret + confirm field; confirm proxies /2fa/totp/confirm, which persists the secret so the next login demands a code (login page already prompts).
  • TOTP disable button (with a confirm gate) proxies /2fa/totp/disable, removing the requirement.
  • Passkey registration runs the browser ceremony and the count (from userinfo.otphkeys) refreshes on success.
  • Wrong confirm codes (server 400 body) and WebAuthn cancellations (NotAllowedError) render inline errors.
  • No external JS or image service for the QR code (inline hand-built SVG; unit test asserts it is standalone).

Verification

just check passes: cargo fmt --check, cargo clippy --all-targets --features server -- -D warnings, cargo check --features server, cargo check --target wasm32-unknown-unknown, and cargo test (added a unit test asserting the TOTP QR SVG is self-contained).

Not exercised here: the behavioral criteria (QR scans in a real authenticator, the next login demanding the code, the passkey ceremony completing on real hardware) need a running vervain-server and a browser, which this environment cannot run. Those are left for live review.

🤖 Generated with Claude Code

## VAPP-13: 2FA enrollment UI: TOTP and WebAuthn passkeys Adds a Security card to Settings that enrolls/confirms/disables TOTP and registers WebAuthn passkeys. Every call proxies vervain-server's session-gated 2FA routes through a server fn that forwards the `vervain_session` token as the `mc_session` cookie the server expects (same pattern as the existing `login` proxy), because the browser does not hold that cookie on the vervain-server origin. ### What changed - **Server fns** (`src/server_fns/mod.rs`): `get_2fa_status` reads the sentinel-replaced `userinfo` frame from the control-channel connect burst (`otpsecret` -> `1` when set, `otphkeys` -> passkey count, so no secret crosses the wire); `totp_start` / `totp_confirm` / `totp_disable` proxy `POST /2fa/totp/*`; `webauthn_register_start` / `webauthn_register_finish` proxy `POST /webauthn/register/*`. Shared `post_authed` attaches the forwarded cookie; `ok_or_body` forwards the server's own error bodies (e.g. "code did not verify") to inline UI errors, and a 401 maps to the auth marker so the existing sign-in banner appears. - **QR** (AC: no external JS or image service): rendered server-side. `render_totp_qr_svg` encodes the `otpauth` URI with the pure-Rust `qrcode` crate (default features off, so neither the image nor svg renderer crate is pulled) and builds a standalone inline SVG by hand from the module matrix. - **WebAuthn ceremony** (`src/webauthn.rs`): runs in the wasm client through a small JS shim inlined by `wasm-bindgen` (bundled into the wasm, no external script). It base64url-decodes the `webauthn-rs` challenge into the `ArrayBuffer`s `navigator.credentials.create` expects, runs the ceremony, and re-encodes the attestation into the `RegisterPublicKeyCredential` JSON `register/finish` deserializes. A `NotAllowedError` (cancel/timeout) and other browser errors surface inline. `web-sys` was avoided because its WebAuthn dictionaries are gated behind the `web_sys_unstable_apis` cfg, which would change the CI build invocation. - **UI** (`src/components/pages/settings.rs`): `SecurityCard` drives all four flows with busy/error state, a two-click confirm gate for disabling (in place of a JS dialog), the QR + manual-secret + code-confirm enroll panel, and a passkey count that refreshes after each registration. New models `TwoFactorStatus` and `TotpEnrollment`. ### Acceptance criteria - [x] TOTP enroll panel: QR (server-rendered SVG) + manual secret + confirm field; confirm proxies `/2fa/totp/confirm`, which persists the secret so the next login demands a code (login page already prompts). - [x] TOTP disable button (with a confirm gate) proxies `/2fa/totp/disable`, removing the requirement. - [x] Passkey registration runs the browser ceremony and the count (from `userinfo.otphkeys`) refreshes on success. - [x] Wrong confirm codes (server 400 body) and WebAuthn cancellations (`NotAllowedError`) render inline errors. - [x] No external JS or image service for the QR code (inline hand-built SVG; unit test asserts it is standalone). ### Verification `just check` passes: `cargo fmt --check`, `cargo clippy --all-targets --features server -- -D warnings`, `cargo check --features server`, `cargo check --target wasm32-unknown-unknown`, and `cargo test` (added a unit test asserting the TOTP QR SVG is self-contained). Not exercised here: the behavioral criteria (QR scans in a real authenticator, the next login demanding the code, the passkey ceremony completing on real hardware) need a running vervain-server and a browser, which this environment cannot run. Those are left for live review. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(settings): 2FA enrollment UI for TOTP and WebAuthn passkeys
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 1m46s
93f7c4b43c
Add a Security card to the Settings page that enrolls, confirms, and disables TOTP and registers WebAuthn passkeys, all proxied through vervain-server's session-gated 2FA routes the same way login proxies /login. The browser never holds the mc_session cookie on the vervain-server origin, so every enrollment call forwards the vervain_session token as mc_session from a server fn.

- New server fns in server_fns/mod.rs: get_2fa_status reads the sentinel-replaced userinfo frame off the control-channel connect burst (otpsecret becomes 1 when a secret is stored, otphkeys becomes the passkey count, so no secret crosses the wire); totp_start/totp_confirm/totp_disable proxy POST /2fa/totp/*; webauthn_register_start/webauthn_register_finish proxy POST /webauthn/register/*. A shared post_authed helper attaches the forwarded cookie and ok_or_body maps the server's own error bodies (e.g. "code did not verify") to inline UI errors.
- TOTP QR is rendered server-side: render_totp_qr_svg encodes the otpauth URI with the pure-Rust qrcode crate and builds a standalone inline SVG by hand from the module matrix (one black rect per dark module plus the 4-module quiet zone), so the page pulls no external JS or image service for the QR.
- WebAuthn ceremony runs in the wasm client via a small JS shim inlined by wasm-bindgen (src/webauthn.rs): it base64url-decodes the webauthn-rs challenge into the ArrayBuffers navigator.credentials.create expects, runs the ceremony, and re-encodes the attestation into the RegisterPublicKeyCredential JSON that register/finish deserializes. A NotAllowedError (user cancel/timeout) and any other browser error surface as inline errors. web-sys was avoided because its WebAuthn dictionaries are gated behind the web_sys_unstable_apis cfg, which would change the CI build invocation; the inlined shim keeps the ceremony self-contained with no external script.
- SecurityCard component drives all four flows with busy/error state, a two-click confirm gate for disabling (in place of a JS dialog), the QR + manual-secret + code-confirm enroll panel, and a passkey count that refreshes after each registration. New models TwoFactorStatus and TotpEnrollment carry the wire shapes.

Compilation verified for both targets plus fmt, clippy (-D warnings), and tests via `just check` (added a unit test asserting the TOTP QR SVG is standalone with no external references). The behavioral acceptance criteria (QR scans in an authenticator, next login demands the code, the passkey ceremony completes) need a running vervain-server and a real browser, which this environment has no way to exercise; they are left for live review.

#VAPP-13

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into feat/2fa-enrollment-ui-vapp-13
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
Check / fmt + clippy + build + tests (pull_request) Successful in 57s
2aaf111698
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	src/models/mod.rs
#	src/server_fns/mod.rs
David merged commit 10a30d5a48 into main 2026-06-07 00:24:55 +02:00
David deleted branch feat/2fa-enrollment-ui-vapp-13 2026-06-07 00:24:56 +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/vervain-apps!11
No description provided.