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

@@ -15,11 +15,12 @@
* 3. an ordinary failure still shows its own message rather than being
* swallowed by the step-up sentence,
* 4. the sentence resolves in more than one locale,
* 5. the sentence names the recovery the web actually has. The mint endpoint
* is Bearer-only by design, so there is no in-page re-proof dialog for a
* cookie session; signing in again is the recovery, and a passkey login
* stamps `mfaVerifiedAt`. If that ever changes, this assertion is the
* reminder that the copy has to change with it.
* 5. the sentence names a recovery that always exists. A refusal that names
* its proofs (`meta.methods`) opens the re-proof dialog instead and never
* reaches this sentence (`useRecentProof`, pinned by
* `step-up-web-caller-guard.test.ts`); what is left is a refusal the
* dialog cannot take up, and signing in again is the recovery there — a
* passkey login stamps `mfaVerifiedAt`.
*
* Mutation check: return `err.message` from the removal's `onError` and cases 1,
* 2 and 4 go red; narrow `describeStepUp` to the exact `auth.stepup.required`
@@ -85,9 +86,9 @@ describe("passkey removal — the step-up refusal a person sees", () => {
});
it("names the recovery the web actually has", () => {
// Not "verify to continue": there is no in-page re-proof for a cookie
// session, because the elevation mint is Bearer-only by design. Signing in
// again is the recovery, and a passkey login stamps the session.
// The dialog handles every refusal that names its proofs; this sentence
// is for the rest, where signing in again is the recovery and a passkey
// login stamps the session.
expect(STEP_UP_SENTENCE.toLowerCase()).toContain("sign in again");
});
@@ -56,6 +56,22 @@ const REAUTH_METHODS: readonly ReauthMethod[] = [
* none. On an account with a second factor the server leaves the password out,
* so the dialog does not offer a choice that can only be refused.
*/
/**
* The refusal says the account holds nothing it could re-prove here: an
* account that signs in through an identity provider only, with no password,
* passkey or second factor. A dialog would only offer it a password it does
* not have, so the callers say "sign in again" instead — a fresh sign-in is
* the proof such an account can give (`Session.createdAt` within five
* minutes).
*/
export function nothingToReprove(err: unknown): boolean {
return (
err instanceof ApiError &&
Array.isArray(err.meta?.methods) &&
err.meta.methods.length === 0
);
}
export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
if (!(err instanceof ApiError)) return null;
const raw = err.meta?.methods;
@@ -43,6 +43,7 @@ import {
} from "@/lib/api/api-fetch";
import { describePasskeyError } from "@/lib/passkey-errors";
import { describeStepUp } from "./security-keys-card";
import { ReproofCancelledError, useRecentProof } from "./use-recent-proof";
interface PasskeyInfo {
id: string;
@@ -164,22 +165,30 @@ export function PasskeyListSection({
onError: (err: Error) => setMsg(err.message),
});
// Removal is step-up gated: a second factor, or on a passkey-only account a
// passkey, proved within five minutes. The server names what clears it and
// the shared dialog collects that, then the removal runs again.
const recentProof = useRecentProof({
description: t("settings.reproof.descriptionChange"),
});
const remove = useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/auth/passkeys/${id}`);
await recentProof.run(() => apiDelete(`/api/auth/passkeys/${id}`));
},
onSuccess: () => {
setMsg(null);
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
},
// Removal is step-up gated. The server's own prose says "second-factor",
// which is wrong for the passkey-only account this card mostly serves — the
// credential to re-prove is the passkey — so the refusal gets its own
// sentence naming the recovery that exists on the web: sign in again.
onError: (err: Error) =>
// A refusal the dialog could not take up keeps its own sentence. The
// server's prose says "second-factor", which is wrong for the passkey-only
// account this card mostly serves, so it names the recovery that always
// exists on the web: sign in again.
onError: (err: Error) => {
if (err instanceof ReproofCancelledError) return;
setMsg(
describeStepUp(err, err.message, t("settings.passkeyStepUpRequired")),
),
);
},
});
const DEVICE_TYPE_LABELS: Record<string, string> = {
@@ -189,6 +198,7 @@ export function PasskeyListSection({
return (
<SettingsCard>
{recentProof.dialog}
<SettingsCardHeader
anchor="passkeys"
icon={Fingerprint}
@@ -0,0 +1,144 @@
import { ApiError } from "@/lib/api/api-fetch";
import {
isReproofRequired,
type ExistingFactorProof,
type ReauthMethod,
} from "./existing-factor-reauth-dialog";
/**
* The re-proof flow without React: catch the refusal, ask for a proof, post
* it, run the action once more. `useRecentProof` binds it to component state
* and the dialog; the unit suite drives it directly, because this repository's
* component tests run without a browser DOM and the flow is what matters.
*/
const KNOWN_METHODS: readonly ReauthMethod[] = [
"password",
"totp",
"webauthn",
"passkey",
];
/** The proofs a refusal says this account can give. */
function methodsFrom(err: ApiError): ReauthMethod[] {
const raw = err.meta?.methods;
if (!Array.isArray(raw)) return [];
return raw.filter((m): m is ReauthMethod =>
KNOWN_METHODS.includes(m as ReauthMethod),
);
}
/**
* A refusal the re-proof dialog can clear.
*
* Two gates answer this way. The recent-proof gate (`auth.reproof.required`)
* in front of exports, share links, tokens, invitations and the admin data
* actions; and the fresh-factor gate (`auth.stepup.required`, from
* `requireFreshMfa`) in front of the managed-profile acts, the second-factor
* changes, passkey removal, the password change, account deletion, the data
* wipe and the key rotation. The second one names the proofs it would take in
* `meta.methods` on a browser session, and a `POST /api/auth/reproof` with one
* of them stamps the session the way signing in with the factor does, so the
* same dialog clears both. A fresh-factor refusal without `methods` (an
* account with no factor at all says `auth.stepup.mfa_not_enrolled` instead)
* is not something a dialog can answer, and stays with the caller's own
* message.
*/
export function isProofRefusal(err: unknown): boolean {
if (isReproofRequired(err)) return true;
return (
err instanceof ApiError &&
err.status === 401 &&
err.meta?.errorCode === "auth.stepup.required" &&
methodsFrom(err).length > 0
);
}
/** The person closed the re-proof dialog. Nothing ran; show nothing. */
export class ReproofCancelledError extends Error {
constructor() {
super("Re-proof cancelled");
this.name = "ReproofCancelledError";
}
}
export interface RecentProofFlowView {
/** Open the dialog offering these proofs, or close it with `null`. */
setMethods: (methods: ReauthMethod[] | null) => void;
setPending: (pending: boolean) => void;
setError: (error: string | null) => void;
}
export interface RecentProofFlow {
run: <T>(action: () => Promise<T>) => Promise<T>;
/** The dialog collected a proof: post it and release the waiting action. */
prove: (proof: ExistingFactorProof) => Promise<void>;
/** The dialog was closed without a proof. */
cancel: () => void;
}
export function createRecentProofFlow(deps: {
view: RecentProofFlowView;
/** Posts the proof to `/api/auth/reproof`. */
submit: (proof: ExistingFactorProof) => Promise<unknown>;
/** Read at the moment they are needed, so a locale switch is honoured. */
messages: () => { signInAgain: string; failed: string };
}): RecentProofFlow {
let waiter: { resolve: () => void; reject: () => void } | null = null;
const run = async <T>(action: () => Promise<T>): Promise<T> => {
try {
return await action();
} catch (err) {
if (!isProofRefusal(err)) throw err;
const offered = methodsFrom(err as ApiError);
// Nothing to re-prove in place: an account that signs in through an
// identity provider only. A fresh sign-in is the proof it can give.
if (offered.length === 0) {
throw new ApiError(deps.messages().signInAgain, 401, {
errorCode: "auth.reproof.sign_in_again",
});
}
await new Promise<void>((resolve, reject) => {
waiter = {
resolve,
reject: () => reject(new ReproofCancelledError()),
};
deps.view.setError(null);
deps.view.setMethods(offered);
});
// Once. A second refusal is the answer, not a reason to ask again.
return action();
}
};
const prove = async (proof: ExistingFactorProof): Promise<void> => {
deps.view.setPending(true);
deps.view.setError(null);
try {
await deps.submit(proof);
deps.view.setMethods(null);
const w = waiter;
waiter = null;
w?.resolve();
} catch (err) {
deps.view.setError(
err instanceof ApiError && err.status === 429
? err.message
: deps.messages().failed,
);
} finally {
deps.view.setPending(false);
}
};
const cancel = () => {
deps.view.setMethods(null);
deps.view.setError(null);
const w = waiter;
waiter = null;
w?.reject();
};
return { run, prove, cancel };
}
@@ -37,10 +37,12 @@ import { formatDate } from "@/lib/format";
import {
ExistingFactorReauthDialog,
isReproofRequired,
nothingToReprove,
offeredReauthMethods,
type ExistingFactorProof,
type ReauthMethod,
} from "./existing-factor-reauth-dialog";
import { ReproofCancelledError, useRecentProof } from "./use-recent-proof";
export interface WebauthnKeyInfo {
id: string;
@@ -133,6 +135,11 @@ export function SecurityKeysCard({
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err, proof) => {
if (isReproofRequired(err) && nothingToReprove(err)) {
setReauthOpen(false);
setError(t("settings.reproof.signInAgain"));
return;
}
if (isReproofRequired(err)) {
setOfferedMethods(offeredReauthMethods(err));
setError(null);
@@ -172,26 +179,35 @@ export function SecurityKeysCard({
),
});
// Removing a key needs the second factor proved within five minutes; the
// shared dialog collects it when the server asks, then the removal runs
// again.
const recentProof = useRecentProof({
description: t("settings.reproof.descriptionChange"),
});
const remove = useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/auth/me/mfa/webauthn/${id}`);
await recentProof.run(() => apiDelete(`/api/auth/me/mfa/webauthn/${id}`));
},
onSuccess: () => {
setError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) =>
onError: (err) => {
if (err instanceof ReproofCancelledError) return;
setError(
describeStepUp(
err,
t("settings.security.keys.removeFailed"),
t("settings.security.stepUpRequired"),
),
),
);
},
});
return (
<SettingsCard>
{recentProof.dialog}
<SettingsCardHeader
anchor="security-keys"
icon={Usb}
@@ -31,11 +31,17 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { RecoveryCodesPanel } from "./recovery-codes-panel";
import {
ReproofCancelledError,
recentProofErrorMessage,
useRecentProof,
} from "./use-recent-proof";
import {
ExistingFactorReauthDialog,
isReproofRequired,
nothingToReprove,
offeredReauthMethods,
type ExistingFactorProof,
type ReauthMethod,
@@ -117,6 +123,33 @@ export function TotpCard({
const [regenerateOpen, setRegenerateOpen] = useState(false);
const [disableOpen, setDisableOpen] = useState(false);
// Disabling the factor and rotating the recovery codes both need the second
// factor proved within five minutes. When it is older, the server names the
// proofs that clear it and the shared dialog collects one, then the action
// runs again.
const recentProof = useRecentProof({
description: t("settings.reproof.descriptionChange"),
});
// Disabling is asked BEFORE the confirmation opens, not after its code was
// typed: the route checks the session's proof before it reads the code, and
// a code typed first and then spent on the re-proof would be refused as a
// replay when the disable is sent again.
async function openDisable() {
setError(null);
try {
await recentProof.run(() => apiGet("/api/auth/reproof"));
} catch (err) {
const message = recentProofErrorMessage(
err,
t("settings.security.totp.disableFailed"),
);
if (message) setError(message);
return;
}
setDisableOpen(true);
}
function resetWizard() {
setSetup(null);
setQrDataUrl(null);
@@ -154,6 +187,11 @@ export function TotpCard({
}
},
onError: (err, proof) => {
if (isReproofRequired(err) && nothingToReprove(err)) {
setReauthOpen(false);
setError(t("settings.reproof.signInAgain"));
return;
}
if (isReproofRequired(err)) {
const offered = offeredReauthMethods(err);
if (offered) setReauthMethods(offered);
@@ -194,31 +232,37 @@ export function TotpCard({
const disable = useMutation({
mutationFn: async () => {
await apiPost("/api/auth/me/mfa/disable", {
code: disableCode,
method: disableMethod,
});
await recentProof.run(() =>
apiPost("/api/auth/me/mfa/disable", {
code: disableCode,
method: disableMethod,
}),
);
},
onSuccess: () => {
setDisableCode("");
setFreshCodes(null);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) =>
onError: (err) => {
if (err instanceof ReproofCancelledError) return;
setError(
describeError(
err,
t("settings.security.totp.disableFailed"),
t("settings.security.stepUpRequired"),
),
),
);
},
onSettled: () => setDisableOpen(false),
});
const regenerate = useMutation({
mutationFn: async () => {
return apiPost<{ recoveryCodes: string[] }>(
"/api/auth/me/mfa/recovery-codes/regenerate",
return recentProof.run(() =>
apiPost<{ recoveryCodes: string[] }>(
"/api/auth/me/mfa/recovery-codes/regenerate",
),
);
},
onSuccess: (data) => {
@@ -226,14 +270,16 @@ export function TotpCard({
setFreshCodes(data.recoveryCodes);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) =>
onError: (err) => {
if (err instanceof ReproofCancelledError) return;
setError(
describeError(
err,
t("settings.security.recovery.regenFailed"),
t("settings.security.stepUpRequired"),
),
),
);
},
onSettled: () => setRegenerateOpen(false),
});
@@ -250,6 +296,7 @@ export function TotpCard({
return (
<SettingsCard data-testid="totp-card">
{recentProof.dialog}
<SettingsCardHeader
anchor="two-factor"
icon={Smartphone}
@@ -471,7 +518,13 @@ export function TotpCard({
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={disableOpen} onOpenChange={setDisableOpen}>
<AlertDialog
open={disableOpen}
onOpenChange={(open) => {
if (open) void openDisable();
else setDisableOpen(false);
}}
>
<AlertDialogTrigger asChild>
<Button
type="button"
@@ -1,39 +1,20 @@
"use client";
import { useCallback, useRef, useState, type ReactNode } from "react";
import { useMemo, useState, type ReactNode } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import {
ExistingFactorReauthDialog,
isReproofRequired,
type ExistingFactorProof,
type ReauthMethod,
} from "./existing-factor-reauth-dialog";
import {
ReproofCancelledError,
createRecentProofFlow,
isProofRefusal,
} from "./recent-proof-flow";
const KNOWN_METHODS: readonly ReauthMethod[] = [
"password",
"totp",
"webauthn",
"passkey",
];
/** The proofs a `auth.reproof.required` refusal says this account can give. */
function methodsFrom(err: ApiError): ReauthMethod[] {
const raw = err.meta?.methods;
if (!Array.isArray(raw)) return [];
return raw.filter((m): m is ReauthMethod =>
KNOWN_METHODS.includes(m as ReauthMethod),
);
}
/** The person closed the re-proof dialog. Nothing ran; show nothing. */
export class ReproofCancelledError extends Error {
constructor() {
super("Re-proof cancelled");
this.name = "ReproofCancelledError";
}
}
export { ReproofCancelledError };
/**
* The sentence to show for an error `run` rejected with, or null when there is
@@ -76,7 +57,7 @@ export async function throwIfReproofRequired(res: Response): Promise<Response> {
401,
meta,
);
if (isReproofRequired(err)) throw err;
if (isProofRefusal(err)) throw err;
return res;
}
@@ -85,8 +66,10 @@ export async function throwIfReproofRequired(res: Response): Promise<Response> {
*
* Exports of the whole record, share links, token minting and the admin data
* actions answer 401 `auth.reproof.required` with `meta.methods` when the
* session has not signed in or re-proved within five minutes. `run` catches
* exactly that refusal, opens the re-proof dialog with the listed methods,
* session has not signed in or re-proved within five minutes; the
* fresh-factor routes answer 401 `auth.stepup.required` with `meta.methods`
* when the second factor is older than that (`isProofRefusal`). `run` catches
* exactly those refusals, opens the re-proof dialog with the listed methods,
* posts the proof to `/api/auth/reproof` and runs the action once more. Every
* other error rejects as usual; a cancelled dialog rejects with
* `ReproofCancelledError`. `recentProofErrorMessage` maps both for display.
@@ -95,7 +78,16 @@ export async function throwIfReproofRequired(res: Response): Promise<Response> {
* sign-on only) gets no dialog: the refusal is rethrown with a message asking
* to sign in again.
*/
export function useRecentProof(): {
export function useRecentProof(
options: {
/**
* The dialog's one line on why it asks. Defaults to the sentence for
* sharing and exporting; the fresh-factor acts (changing who looks after a
* record, a second factor, the account itself) pass their own.
*/
description?: string;
} = {},
): {
run: <T>(action: () => Promise<T>) => Promise<T>;
dialog: ReactNode;
} {
@@ -103,57 +95,18 @@ export function useRecentProof(): {
const [methods, setMethods] = useState<ReauthMethod[] | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const waiter = useRef<{
resolve: () => void;
reject: () => void;
} | null>(null);
const run = useCallback(
async <T,>(action: () => Promise<T>): Promise<T> => {
try {
return await action();
} catch (err) {
if (!isReproofRequired(err)) throw err;
const offered = methodsFrom(err as ApiError);
if (offered.length === 0) {
throw new ApiError(t("settings.reproof.signInAgain"), 401, {
errorCode: "auth.reproof.sign_in_again",
});
}
await new Promise<void>((resolve, reject) => {
waiter.current = {
resolve,
reject: () => reject(new ReproofCancelledError()),
};
setError(null);
setMethods(offered);
});
return action();
}
},
[t],
);
const onProof = useCallback(
async (proof: ExistingFactorProof) => {
setPending(true);
setError(null);
try {
await apiPost("/api/auth/reproof", proof);
setMethods(null);
const w = waiter.current;
waiter.current = null;
w?.resolve();
} catch (err) {
setError(
err instanceof ApiError && err.status === 429
? err.message
: t("settings.reproof.failed"),
);
} finally {
setPending(false);
}
},
// One flow per locale, as the callbacks it replaces were (`[t]`): it holds
// the waiting action, so it is not rebuilt on every render.
const { run, prove, cancel } = useMemo(
() =>
createRecentProofFlow({
view: { setMethods, setPending, setError },
submit: (proof) => apiPost("/api/auth/reproof", proof),
messages: () => ({
signInAgain: t("settings.reproof.signInAgain"),
failed: t("settings.reproof.failed"),
}),
}),
[t],
);
@@ -162,18 +115,13 @@ export function useRecentProof(): {
key={methods?.join(",") ?? "closed"}
open={methods !== null}
onOpenChange={(open) => {
if (open) return;
setMethods(null);
setError(null);
const w = waiter.current;
waiter.current = null;
w?.reject();
if (!open) cancel();
}}
methods={methods ?? ["password"]}
pending={pending}
error={error}
onProof={(proof) => void onProof(proof)}
description={t("settings.reproof.description")}
onProof={(proof) => void prove(proof)}
description={options.description ?? t("settings.reproof.description")}
/>
);