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

+24 -6
View File
@@ -45,6 +45,11 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { getApiErrorMessage } from "./_shared";
import {
recentProofErrorMessage,
throwIfReproofRequired,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import { KeyBackupCard } from "./key-backup-card";
import { useKeyBackupStatus } from "./use-key-backup-status";
@@ -105,12 +110,22 @@ export function EncryptionSection() {
staleTime: 15_000,
});
// Rotation needs the admin's second factor proved within five minutes. When
// it is older the server names the proofs that clear it, the shared dialog
// collects one, and the rotation is queued once more.
const recentProof = useRecentProof({
description: t("settings.reproof.descriptionChange"),
});
const rotate = useMutation({
mutationFn: async () => {
const res = await apiFetchRaw("/api/admin/encryption/rotate", {
method: "POST",
credentials: "include",
});
const res = await recentProof.run(async () =>
throwIfReproofRequired(
await apiFetchRaw("/api/admin/encryption/rotate", {
method: "POST",
credentials: "include",
}),
),
);
if (!res.ok) {
if (res.status === 401) {
throw new Error(t("admin.section.encryption.stepUpNeeded"));
@@ -126,10 +141,12 @@ export function EncryptionSection() {
});
},
onError: (err: Error) => {
showErrorToast(
const message = recentProofErrorMessage(
err,
err.message || t("admin.section.encryption.rotateFailed"),
{ cause: err },
);
if (message === null) return;
showErrorToast(message, { cause: err });
},
});
@@ -182,6 +199,7 @@ export function EncryptionSection() {
return (
<div className="space-y-6">
{recentProof.dialog}
<KeyBackupCard />
{/* ── Coverage summary ───────────────────────────────────────── */}
<SettingsCard>