fix(auth): adding a credential beside a second factor needs that factor

On an account with a second factor, TOTP setup, security-key registration
and passkey registration no longer accept the password, a password re-proof
or a session younger than five minutes. A remembered browser skips the
second factor at sign-in and still creates a fresh session, so the password
alone could add a key that then satisfies every gate the factor protected.
Only a TOTP code, a security key, a passkey or a completed second factor
within five minutes opens enrollment there; the Bearer arm of TOTP setup and
security-key registration asks the same of its elevation. Accounts without
a second factor keep the previous rules.

The refusal names the proofs the account can give, and the enrollment
dialogs offer exactly those. A proof that verifies now gives its attempt
back to the shared re-proof budget, including the current password on a
password change.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 22:16:44 +02:00
1 parent 66b1aef40c
commit 0692017f34
14 files changed
+519 -88

No files matched your search

@@ -44,6 +44,28 @@ export function isReproofRequired(err: unknown): boolean {
);
}
const REAUTH_METHODS: readonly ReauthMethod[] = [
"password",
"totp",
"passkey",
"webauthn",
];
/**
* The proofs the refusal says this account can give, or null when it names
* 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.
*/
export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
if (!(err instanceof ApiError)) return null;
const raw = err.meta?.methods;
if (!Array.isArray(raw)) return null;
const offered = raw.filter((m): m is ReauthMethod =>
REAUTH_METHODS.includes(m as ReauthMethod),
);
return offered.length > 0 ? offered : null;
}
/**
* 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
@@ -36,6 +36,7 @@ import { formatDate } from "@/lib/format";
import {
ExistingFactorReauthDialog,
isReproofRequired,
offeredReauthMethods,
type ExistingFactorProof,
type ReauthMethod,
} from "./existing-factor-reauth-dialog";
@@ -98,8 +99,14 @@ export function SecurityKeysCard({
// 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",
// An account with a second factor confirms with that factor or a passkey;
// the server refuses a password there and names what it will take.
const hasSecondFactor = totpEnabled || keys.length > 0;
const [offeredMethods, setOfferedMethods] = useState<ReauthMethod[] | null>(
null,
);
const reauthMethods: ReauthMethod[] = offeredMethods ?? [
...(hasSecondFactor ? [] : (["password"] as const)),
...(totpEnabled ? (["totp"] as const) : []),
"passkey",
...(keys.length > 0 ? (["webauthn"] as const) : []),
@@ -126,6 +133,7 @@ export function SecurityKeysCard({
},
onError: (err, proof) => {
if (isReproofRequired(err)) {
setOfferedMethods(offeredReauthMethods(err));
setError(null);
setReauthError(null);
setReauthOpen(true);
@@ -36,7 +36,9 @@ import { RecoveryCodesPanel } from "./recovery-codes-panel";
import {
ExistingFactorReauthDialog,
isReproofRequired,
offeredReauthMethods,
type ExistingFactorProof,
type ReauthMethod,
} from "./existing-factor-reauth-dialog";
interface SetupData {
@@ -128,6 +130,11 @@ export function TotpCard({
// call is retried with it from the dialog.
const [reauthOpen, setReauthOpen] = useState(false);
const [reauthError, setReauthError] = useState<string | null>(null);
const [reauthMethods, setReauthMethods] = useState<ReauthMethod[]>([
"password",
"passkey",
"webauthn",
]);
const beginSetup = useMutation({
mutationFn: async (proof?: ExistingFactorProof) => {
@@ -148,6 +155,8 @@ export function TotpCard({
},
onError: (err, proof) => {
if (isReproofRequired(err)) {
const offered = offeredReauthMethods(err);
if (offered) setReauthMethods(offered);
setError(null);
setReauthError(null);
setReauthOpen(true);
@@ -278,7 +287,7 @@ export function TotpCard({
setReauthOpen(open);
if (!open) setReauthError(null);
}}
methods={["password", "passkey", "webauthn"]}
methods={reauthMethods}
pending={beginSetup.isPending}
error={reauthError}
onProof={(proof) => beginSetup.mutate(proof)}