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

+14 -1
View File
@@ -1321,7 +1321,20 @@ export async function requireFreshMfa(
});
const verifiedAt = row?.mfaVerifiedAt ?? null;
if (!verifiedAt || verifiedAt.getTime() < Date.now() - maxAgeSeconds * 1000) {
throw new StepUpRequiredError();
// Name the proofs that clear this gate, the way the recent-proof gate
// does, so the browser can open the re-proof dialog instead of printing a
// sentence with nowhere to act on it. Only the possession proofs: a
// `POST /api/auth/reproof` with one of them stamps `mfaVerifiedAt`, a
// password stamps `reproofAt` only and would leave this gate shut.
const methods = (await recentProofMethods(sessionData.user)).filter(
(method) => method !== "password",
);
annotate({ meta: { step_up_methods: methods.join(",") } });
throw new StepUpRequiredError(
"auth.stepup.required",
"Recent second-factor verification required",
{ methods },
);
}
return { ...sessionData, authMethod: "cookie", mfaVerifiedAt: verifiedAt };