fix(auth): require a fresh proof before a second factor is added

A browser session on its own could start TOTP enrollment or register a
security key. Combined with a pending secret counting as a factor, that let
a stolen session give itself a second factor, register a passkey with it,
and keep access after the owner changed the password.

TOTP setup and security-key registration now pass on the cookie path only
when the session signed in or completed a second factor in the last five
minutes, or when the body carries a proof in the step-up shape (password,
a confirmed authenticator code, or a passkey or security-key assertion).
The settings cards ask for that proof when the server answers
auth.reproof.required and retry the same call with it. The Bearer path is
unchanged: its elevation already is the proof.

Every route that checks a password or a factor as a re-proof now uses one
verifier and one per-account budget of five attempts per fifteen minutes,
shared with the step-up mint, and records each refused proof as
auth.mfa.failed. Passkey enrollment and password change had neither the
limit (passkey enrollment) nor the audit row (both). A structural test
freezes the set of credential-verifying routes and requires a throttle and
an audit call in each.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 15:55:57 +02:00
1 parent e1718e3c30
commit 7b5bc130d8
27 files changed
+1763 -584

No files matched your search

@@ -0,0 +1,63 @@
/**
* TOTP and security-key enrollment ask for a fresh proof when the server says
* so (401 `auth.reproof.required`), and retry the SAME enrollment call with it.
*
* Source-oriented like the passkey reauth test next door: the repository keeps
* component tests free of a browser DOM harness. The behavioural half — that
* the server refuses without the proof — is pinned against Postgres in
* `tests/integration/credential-enrollment-proof.test.ts`.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/api/api-fetch";
import { isReproofRequired } from "../existing-factor-reauth-dialog";
const read = (file: string) =>
readFileSync(resolve(__dirname, "..", file), "utf8");
describe("isReproofRequired", () => {
it("recognises only the re-proof refusal", () => {
expect(
isReproofRequired(
new ApiError("x", 401, { errorCode: "auth.reproof.required" }),
),
).toBe(true);
expect(
isReproofRequired(
new ApiError("x", 401, { errorCode: "auth.reproof.failed" }),
),
).toBe(false);
expect(
isReproofRequired(
new ApiError("x", 401, { errorCode: "auth.stepup.required" }),
),
).toBe(false);
expect(isReproofRequired(new Error("x"))).toBe(false);
});
});
describe.each([
["totp-card.tsx", '"/api/auth/me/mfa/totp/setup"', "beginSetup"],
[
"security-keys-card.tsx",
'"/api/auth/me/mfa/webauthn/register/options"',
"add",
],
])("%s", (file, endpoint, mutation) => {
const source = read(file);
it("sends the collected proof on the enrollment call itself", () => {
expect(source).toContain(endpoint);
expect(source).toMatch(
new RegExp(`${endpoint.replace(/[/.]/g, "\\$&")},\\s*proof\\)`),
);
});
it("opens the re-proof dialog on the server's cue and retries with the proof", () => {
expect(source).toContain("isReproofRequired(err)");
expect(source).toContain("setReauthOpen(true)");
expect(source).toContain(`onProof={(proof) => ${mutation}.mutate(proof)}`);
});
});
@@ -0,0 +1,230 @@
"use client";
import { useState } from "react";
import { AlertTriangle, Loader2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { describePasskeyError } from "@/lib/passkey-errors";
export type ReauthMethod = "password" | "totp" | "passkey" | "webauthn";
/** The body the enrollment routes accept as a fresh existing-factor proof. */
export type ExistingFactorProof =
| { method: "password"; password: string }
| { method: "totp"; code: string }
| {
method: "passkey" | "webauthn";
challengeId: string;
credential: unknown;
};
/**
* The server answers an enrollment it will not take on the session alone with
* 401 `auth.reproof.required`. That is the cue to ask for a proof and retry,
* not an error to show.
*/
export function isReproofRequired(err: unknown): boolean {
return (
err instanceof ApiError &&
err.status === 401 &&
err.meta?.errorCode === "auth.reproof.required"
);
}
/**
* Collect a fresh proof of a credential the account already holds, before a
* new factor is added. A passkey or security-key assertion begins at the
* cookie-only `/api/auth/passkey/register-options` re-proof arm, which issues
* an assertion challenge and nothing else; the resulting proof is handed to
* `onProof`, which retries the enrollment call with it.
*/
export function ExistingFactorReauthDialog({
open,
onOpenChange,
methods,
pending,
error,
onProof,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
methods: ReauthMethod[];
pending: boolean;
error: string | null;
onProof: (proof: ExistingFactorProof) => void;
}) {
const { t } = useTranslations();
const [method, setMethod] = useState<ReauthMethod>(methods[0] ?? "password");
const [secret, setSecret] = useState("");
const [assertionError, setAssertionError] = useState<string | null>(null);
const [asserting, setAsserting] = useState(false);
const busy = pending || asserting;
const shown = assertionError ?? error;
async function submit() {
setAssertionError(null);
if (method === "password") {
onProof({ method, password: secret });
return;
}
if (method === "totp") {
onProof({ method, code: secret });
return;
}
setAsserting(true);
try {
const webauthn = await import("@simplewebauthn/browser");
const begun = await apiPost<{
options: Parameters<
typeof webauthn.startAuthentication
>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/register-options", { method });
const credential = await webauthn.startAuthentication({
optionsJSON: begun.options,
});
onProof({ method, challengeId: begun.challengeId, credential });
} catch (err) {
if (err instanceof ApiError) {
setAssertionError(err.message);
} else {
const { key, params } = describePasskeyError(err);
setAssertionError(t(key, params));
}
} finally {
setAsserting(false);
}
}
const labels: Record<ReauthMethod, string> = {
password: t("settings.passkeyReauth.methods.password"),
totp: t("settings.passkeyReauth.methods.totp"),
passkey: t("settings.passkeyReauth.methods.passkey"),
webauthn: t("settings.passkeyReauth.methods.webauthn"),
};
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (busy) return;
if (!next) {
setSecret("");
setAssertionError(null);
}
onOpenChange(next);
}}
>
<AlertDialogContent data-testid="factor-reauth-dialog">
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.passkeyReauth.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.passkeyReauth.factorDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
{methods.length > 1 && (
<div
className="grid grid-cols-2 gap-2"
role="group"
aria-label={t("settings.passkeyReauth.methodLabel")}
>
{methods.map((m) => (
<Button
key={m}
type="button"
variant={method === m ? "secondary" : "outline"}
aria-pressed={method === m}
disabled={busy}
onClick={() => {
setMethod(m);
setSecret("");
setAssertionError(null);
}}
>
{labels[m]}
</Button>
))}
</div>
)}
{method === "password" && (
<Input
type="password"
data-testid="factor-reauth-password"
autoComplete="current-password"
value={secret}
onChange={(event) => setSecret(event.target.value)}
placeholder={t("settings.passkeyReauth.currentPassword")}
aria-label={t("settings.passkeyReauth.currentPassword")}
autoFocus
/>
)}
{method === "totp" && (
<Input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
value={secret}
onChange={(event) =>
setSecret(event.target.value.replace(/\D/g, ""))
}
placeholder={t("settings.passkeyReauth.authenticatorPlaceholder")}
aria-label={t("settings.passkeyReauth.authenticatorCode")}
autoFocus
/>
)}
{shown && (
<div
role="alert"
className="text-destructive flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4 shrink-0" />
{shown}
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="factor-reauth-submit"
disabled={
busy ||
((method === "password" || method === "totp") &&
secret.length === 0)
}
aria-busy={busy || undefined}
onClick={(event) => {
event.preventDefault();
void submit();
}}
>
{busy && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.passkeyReauth.verifyAndContinue")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -63,7 +63,10 @@ export function SecuritySection() {
recoveryCodesRemaining={status.recoveryCodesRemaining}
/>
<SecurityKeysCard keys={status.webauthn} />
<SecurityKeysCard
keys={status.webauthn}
totpEnabled={status.totp.enabled}
/>
<PasskeyListSection isAuthenticated={isAuthenticated} />
</>
@@ -33,6 +33,12 @@ import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { describePasskeyError } from "@/lib/passkey-errors";
import { formatDate } from "@/lib/format";
import {
ExistingFactorReauthDialog,
isReproofRequired,
type ExistingFactorProof,
type ReauthMethod,
} from "./existing-factor-reauth-dialog";
export interface WebauthnKeyInfo {
id: string;
@@ -74,20 +80,38 @@ export function describeStepUp(
return fallback;
}
export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
export function SecurityKeysCard({
keys,
totpEnabled = false,
}: {
keys: WebauthnKeyInfo[];
totpEnabled?: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
// Adding a key right after signing in goes straight through; later, the
// server asks for a fresh proof first (401 `auth.reproof.required`) and the
// options call is retried with it from the dialog.
const [reauthOpen, setReauthOpen] = useState(false);
const [reauthError, setReauthError] = useState<string | null>(null);
const reauthMethods: ReauthMethod[] = [
"password",
...(totpEnabled ? (["totp"] as const) : []),
"passkey",
...(keys.length > 0 ? (["webauthn"] as const) : []),
];
const add = useMutation({
mutationFn: async () => {
mutationFn: async (proof?: ExistingFactorProof) => {
const { startRegistration } = await import("@simplewebauthn/browser");
const { options, challengeId } = await apiPost<{
options: Parameters<typeof startRegistration>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/me/mfa/webauthn/register/options");
}>("/api/auth/me/mfa/webauthn/register/options", proof);
const credential = await startRegistration({ optionsJSON: options });
await apiPost("/api/auth/me/mfa/webauthn/register/verify", {
challengeId,
@@ -96,14 +120,28 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
},
onSuccess: () => {
setError(null);
setReauthOpen(false);
setReauthError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
},
onError: (err) => {
onError: (err, proof) => {
if (isReproofRequired(err)) {
setError(null);
setReauthError(null);
setReauthOpen(true);
return;
}
let message: string;
if (err instanceof ApiError) {
setError(err.message || t("settings.security.keys.addFailed"));
message = err.message || t("settings.security.keys.addFailed");
} else {
const { key, params } = describePasskeyError(err);
setError(t(key, params));
message = t(key, params);
}
if (proof) {
setReauthError(message);
} else {
setError(message);
}
},
});
@@ -294,7 +332,7 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
type="button"
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={() => add.mutate()}
onClick={() => add.mutate(undefined)}
disabled={add.isPending}
>
{add.isPending ? (
@@ -305,6 +343,17 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
{t("settings.security.keys.add")}
</Button>
</div>
<ExistingFactorReauthDialog
open={reauthOpen}
onOpenChange={(open) => {
setReauthOpen(open);
if (!open) setReauthError(null);
}}
methods={reauthMethods}
pending={add.isPending}
error={reauthError}
onProof={(proof) => add.mutate(proof)}
/>
{error && (
<div
@@ -33,6 +33,11 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { RecoveryCodesPanel } from "./recovery-codes-panel";
import {
ExistingFactorReauthDialog,
isReproofRequired,
type ExistingFactorProof,
} from "./existing-factor-reauth-dialog";
interface SetupData {
otpauthUri: string;
@@ -118,12 +123,20 @@ export function TotpCard({
setError(null);
}
// Enrollment right after signing in goes straight through; later, the server
// asks for a fresh proof first (401 `auth.reproof.required`), and the setup
// call is retried with it from the dialog.
const [reauthOpen, setReauthOpen] = useState(false);
const [reauthError, setReauthError] = useState<string | null>(null);
const beginSetup = useMutation({
mutationFn: async () => {
return apiPost<SetupData>("/api/auth/me/mfa/totp/setup");
mutationFn: async (proof?: ExistingFactorProof) => {
return apiPost<SetupData>("/api/auth/me/mfa/totp/setup", proof);
},
onSuccess: async (data) => {
setError(null);
setReauthOpen(false);
setReauthError(null);
setSetup(data);
try {
const QRCode = (await import("qrcode")).default;
@@ -133,14 +146,21 @@ export function TotpCard({
setQrDataUrl(null);
}
},
onError: (err) =>
setError(
describeError(
err,
t("settings.security.totp.setupFailed"),
t("settings.security.stepUpRequired"),
),
),
onError: (err, proof) => {
if (isReproofRequired(err)) {
setError(null);
setReauthError(null);
setReauthOpen(true);
return;
}
const message = describeError(
err,
t("settings.security.totp.setupFailed"),
t("settings.security.stepUpRequired"),
);
if (proof) setReauthError(message);
else setError(message);
},
});
const confirm = useMutation({
@@ -241,7 +261,7 @@ export function TotpCard({
type="button"
data-testid="totp-setup-start"
className="min-h-11 sm:min-h-9"
onClick={() => beginSetup.mutate()}
onClick={() => beginSetup.mutate(undefined)}
disabled={beginSetup.isPending}
>
{beginSetup.isPending ? (
@@ -252,6 +272,17 @@ export function TotpCard({
{t("settings.security.totp.setUp")}
</Button>
)}
<ExistingFactorReauthDialog
open={reauthOpen}
onOpenChange={(open) => {
setReauthOpen(open);
if (!open) setReauthError(null);
}}
methods={["password", "passkey", "webauthn"]}
pending={beginSetup.isPending}
error={reauthError}
onProof={(proof) => beginSetup.mutate(proof)}
/>
{/* ── Enable wizard ── */}
{setup && (