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

+10 -5
View File
@@ -2333,7 +2333,11 @@ paths:
/api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the
admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401
`auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or
re-proved more than five minutes ago.
re-proved more than five minutes ago. A `totp`, `webauthn` or `passkey` proof here also clears the routes that
ask for a fresh second factor (the managed-profile acts, the second-factor and passkey changes, the password
change, account deletion, the data wipe and key rotation): on a browser session those answer 401
`auth.stepup.required` with `meta.methods` naming the possession proofs the account holds, and the web re-proves
here and retries.
The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`,
@@ -21681,10 +21685,11 @@ paths:
schema:
$ref: "#/components/schemas/ManagedProfileEnvelope"
"401":
description: "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`), or none enrolled at all
(`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a
second factor cannot create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives
here too, including for a Bearer caller, whose transport this gate does not read."
description: "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the
proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode:
auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a second factor cannot
create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives here too, including
for a Bearer caller, whose transport this gate does not read."
content:
application/json:
schema: