fix(auth): let every fresh-factor refusal open the re-proof dialog

A Guardian signed in through single sign-on with a second factor enrolled
could not hand a managed profile over once five minutes had passed since
sign-in: the handover answered 401 auth.stepup.required, and the panel
printed "Confirm your second factor, then try again" with no way to do so.

The same held for every action behind requireFreshMfa: creating, editing
and ending a managed profile, adding and removing a Guardian, disabling
the second factor, rotating recovery codes, removing a security key or a
passkey, changing the password, the data wipe, account deletion and the
key rotation. The recent-proof routes had the dialog; these had text.

- requireFreshMfa names the proofs that clear a stale stamp in
  meta.methods (the possession proofs the account holds, never a
  password, which stamps reproofAt only).
- The re-proof flow treats that refusal like auth.reproof.required:
  open the dialog, post the proof to /api/auth/reproof, send the request
  once more. The flow moves into a React-free module so it can be driven
  in tests; the hook binds it to the dialog.
- The managed-profile mutation hooks take a required proof guard, and
  every caller passes recentProof.run. The other surfaces wrap their
  requests in recentProof.run; disabling the second factor asks before
  its confirmation opens, so the code typed there is not spent twice.
- An account with nothing to re-prove in place (single sign-on only, no
  factor) is told to sign in again when adding a factor, instead of being
  offered a password it does not have.
- step-up-web-caller-guard freezes the gated route methods and fails when
  a web request to one of them runs outside the flow.

An account whose only second factor is the identity provider's is still
asked for an app factor on the managed-profile acts: the app cannot see
the provider's factor. Enrolling right after a sign-in needs no further
proof, and the confirmation stamps the session, so the act follows.

Refs #959
This commit is contained in:
Marc-André Bombeck committed 2026-10-17 09:17:03 +02:00
1 parent 59f910df5e
commit 99f0607a69
36 files changed
+2122 -466

No files matched your search

+5 -1
View File
@@ -6,7 +6,11 @@
* token, or run an admin backup, restore, wipe or reset. Those routes answer
* 401 `auth.reproof.required` with `meta.methods` when the session carries no
* recent proof; the web opens the re-proof dialog, posts the proof here, and
* retries.
* retries. The fresh-factor routes (`requireFreshMfa`: the managed-profile
* acts, second-factor and passkey changes, the password change, account
* deletion, the data wipe, key rotation) answer 401 `auth.stepup.required`
* with `meta.methods` on a browser session, and the same dialog clears them
* here with a possession proof.
*
* What a proof stamps on the session row:
* - `totp`, `webauthn` or `passkey`: `mfaVerifiedAt` and `reproofAt`. The