Let a passkey satisfy the passkey-removal step-up

The gate landed with a softer mode for accounts holding no second factor,
on the premise that they could not clear it. That premise was wrong. A
passkey is a possession factor here and the codebase already says so in
three places: a primary passkey login stamps Session.mfaVerifiedAt,
passkey sits in FRESH_FACTOR_METHODS so a passkey-proved elevation reaches
the fresh-factor routes, and the enrolment policy counts a primary passkey
as satisfying it outright. The only thing refusing such an account was the
reachability pre-check inside requireFreshMfa, which counts second factors
and nothing else.

So the mode is gone and the route asks for a full freshFactor: true with
proofSource: "any-possession" — a second knob that widens which credentials
count as "could ever prove this", and changes nothing about the gate. A
passkey-only account now gets a real gate with the re-provable
auth.stepup.required rather than no gate at all, and the dead-end
auth.stepup.mfa_not_enrolled is reserved for an account holding nothing.

hasSecondFactorEnrolled is deliberately NOT widened. It answers a different
question, and requireFreshMfaIfEnrolled asks it before deciding whether
account deletion, the data reset, the password change, the encrypted export
and key rotation gate at all — counting a passkey there would change five
destructive actions in passing. The two predicates sit beside each other
with docblocks saying why they differ, and collapsing them turns eight
existing tests red across three files.

Recovery is named rather than assumed. On Bearer: mint an elevation with
method "passkey". On the web: sign in again, which is what stamps the
session — there is no in-page re-proof for a cookie session because the
mint endpoint is Bearer-only by design. That limit is written into the
route docblock instead of left to be discovered, and the removal's refusal
now renders its own sentence in all six locales rather than the server's
English "second-factor" prose, which was wrong for the account this card
mostly serves.
This commit is contained in:
Marc-André Bombeck committed 2026-08-23 17:44:13 +02:00
1 parent 283699db63
commit 4e182862c5
17 files changed
+469 -147

No files matched your search

+92 -53
View File
@@ -1046,10 +1046,38 @@ export const MFA_STEP_UP_MAX_AGE_SECONDS = 5 * 60;
export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date };
/**
* Which credentials count when asking "can this account produce a fresh-factor
* proof at all".
*
* The gate is the same either way — a `Session.mfaVerifiedAt` inside the window
* on the cookie path, an elevation minted from a `FRESH_FACTOR_METHODS` proof on
* the Bearer path. What differs is the pre-check that decides whether refusing
* would be a gate or a dead end, and the honest answer to that depends on what
* the route is protecting.
*
* `second-factor` — a confirmed TOTP secret or a registered second-factor
* security key. The default, and the right question for the routes that MANAGE
* a second factor: there is nothing there to protect if nothing is enrolled.
*
* `any-possession` — the above, OR a registered PRIMARY passkey. The right
* question for a route protecting the passkey itself, because a passkey is a
* possession factor in its own right and this codebase already treats it as
* one in three places: `/api/auth/passkey/login-verify` stamps `mfaVerifiedAt`
* on a passkey login (v1.23, M-review M1), `passkey` is in
* `FRESH_FACTOR_METHODS` so a passkey-proved elevation reaches the fresh-factor
* routes, and `resolveMfaEnrollmentRequired` counts a primary passkey as
* satisfying the enforcement policy outright. A passkey-only account can
* therefore clear the gate on both transports, and asking the narrower
* question would have thrown `mfa_not_enrolled` at an account that was holding
* the proof all along.
*/
export type FreshFactorProofSource = "second-factor" | "any-possession";
/**
* v1.23 — step-up gate. Passes only for a COOKIE session whose
* `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user has an
* active second factor (`totpConfirmedAt`). Throws `StepUpRequiredError`
* `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user holds a
* credential that can produce such a stamp. Throws `StepUpRequiredError`
* (401, `errorCode: "auth.stepup.required"`) otherwise.
*
* Bearer tokens can NEVER satisfy this — exactly like `requireAdmin`, the
@@ -1059,9 +1087,14 @@ export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date };
*
* Consumed in Phase M by MFA disable + recovery-code regeneration; later
* waves gate account deletion, key rotation, and passphrase export on it.
*
* @param proofSource which credentials count as "can prove a factor". Defaults
* to the second-factor set every existing caller means; see
* {@link FreshFactorProofSource}.
*/
export async function requireFreshMfa(
maxAgeSeconds: number,
proofSource: FreshFactorProofSource = "second-factor",
): Promise<FreshMfaContext> {
const sessionData = await getSession();
if (!sessionData) throw new HttpError(401, "Not authenticated");
@@ -1075,12 +1108,11 @@ export async function requireFreshMfa(
});
}
// The user must actually have a second factor active. A single-factor
// account cannot produce a fresh-MFA proof, so step-up-gated actions are
// unreachable for it by design (the management UI gates enrolment first).
// Either factor counts: a confirmed TOTP secret OR a registered WebAuthn
// security key — both stamp `Session.mfaVerifiedAt` on a completed login.
if (!(await hasSecondFactorEnrolled(sessionData.user))) {
// The account must hold a credential that can stamp `mfaVerifiedAt` in the
// first place. Without one a step-up-gated action is unreachable by design
// rather than merely refused, so the refusal is named differently
// (`mfa_not_enrolled`) and the management UI gates enrolment ahead of it.
if (!(await canProveFreshFactor(sessionData.user, proofSource))) {
throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled");
}
@@ -1127,7 +1159,7 @@ export async function requireFreshMfaIfEnrolled(
}
/**
* Does this account have a second factor at all?
* Does this account have a SECOND FACTOR at all?
*
* Either factor enrols it: a confirmed TOTP secret OR a registered WebAuthn
* security key. A webauthn-only account must clear step-up too, so every
@@ -1135,6 +1167,17 @@ export async function requireFreshMfaIfEnrolled(
* rule rather than reading `totpConfirmedAt` alone. It was written out three
* times before it was a function, which is two more places for the second arm
* to be forgotten.
*
* DELIBERATELY DOES NOT COUNT A PRIMARY PASSKEY, even though a passkey login
* stamps `mfaVerifiedAt` and could therefore satisfy the gate. This predicate
* answers "is there a second factor", and that is the question
* `requireFreshMfaIfEnrolled` asks before deciding whether account deletion, the
* data reset, the password change, the encrypted export and key rotation demand
* a step-up at all. Counting a passkey here would silently pull every
* passkey-holding account into a gate those routes have never applied to them —
* a behaviour change to five destructive actions, made in passing, for the
* benefit of a sixth. {@link canProveFreshFactor} is where the wider question
* lives.
*/
async function hasSecondFactorEnrolled(user: {
id: string;
@@ -1147,6 +1190,27 @@ async function hasSecondFactorEnrolled(user: {
return webauthnKeyCount > 0;
}
/**
* Can this account produce a fresh-factor proof at all?
*
* The wider question, asked by the routes that gate on possession rather than
* on second-factor management. See {@link FreshFactorProofSource} for why a
* primary passkey answers yes and where the codebase already says so; see
* {@link hasSecondFactorEnrolled} for why the narrower predicate must not be
* widened into this one.
*/
async function canProveFreshFactor(
user: { id: string; totpConfirmedAt: Date | null },
proofSource: FreshFactorProofSource,
): Promise<boolean> {
if (await hasSecondFactorEnrolled(user)) return true;
if (proofSource === "second-factor") return false;
const passkeyCount = await prisma.passkey.count({
where: { userId: user.id },
});
return passkeyCount > 0;
}
/**
* A Bearer-resolved caller.
*
@@ -1274,34 +1338,28 @@ export type MfaManagementContext = {
*
* @param options.freshFactor mirrors the cookie path's `requireFreshMfa`. Set by
* the destructive routes (disable, recovery-code rotation, security-key
* removal).
*
* `"if-enrolled"` is the third value and it is the one that needs explaining.
* `true` refuses an account with no second factor outright, which is correct
* for the routes that MANAGE a second factor — there is nothing to remove if
* nothing is enrolled. Passkey removal is not like that: the passkey is the
* primary sign-in credential, most accounts holding one have no second factor
* beside it, and a gate they cannot satisfy would not be a safeguard but a
* locked door in front of their own credential list. So `"if-enrolled"`
* applies the full gate to the accounts that can clear it and leaves the rest
* on the plain `requireAuth()` contract they had, which is the same line
* `requireFreshMfaIfEnrolled` draws for the destructive account actions.
* removal, passkey removal).
* @param options.proofSource which credentials count as "can prove a factor"
* when deciding whether the gate is reachable. Defaults to the second-factor
* set. Passkey removal passes `"any-possession"` because a primary passkey IS
* the proof there — see {@link FreshFactorProofSource}. It changes only the
* reachability pre-check; the gate itself is identical either way.
*/
export async function requireMfaManagementAuth(
options: { freshFactor?: boolean | "if-enrolled" } = {},
options: {
freshFactor?: boolean;
proofSource?: FreshFactorProofSource;
} = {},
): Promise<MfaManagementContext> {
const freshFactorMode = options.freshFactor ?? false;
const freshFactor = options.freshFactor === true;
const proofSource = options.proofSource ?? "second-factor";
// Cookie first, and via the original helpers — the web path runs the same
// code it always did.
const sessionData = await getSession();
if (sessionData) {
const freshFactor =
freshFactorMode === "if-enrolled"
? await hasSecondFactorEnrolled(sessionData.user)
: freshFactorMode;
const resolved = freshFactor
? await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS)
? await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS, proofSource)
: await requireCookieAuth();
return {
transport: "cookie",
@@ -1315,24 +1373,6 @@ export async function requireMfaManagementAuth(
// token is refused here and never gets as far as presenting an elevation.
const auth = await requireBearerAuth();
// Resolved BEFORE the header is read, because on `"if-enrolled"` an account
// with no second factor needs no elevation and must not be asked for one —
// demanding a header it can never mint would be the lockout this mode exists
// to avoid, wearing a 401.
const freshFactor =
freshFactorMode === "if-enrolled"
? await hasSecondFactorEnrolled(auth.user)
: freshFactorMode;
if (freshFactorMode === "if-enrolled" && !freshFactor) {
return {
transport: "bearer",
user: auth.user,
apiTokenId: auth.apiTokenId,
accessTokenHash: auth.accessTokenHash,
commitElevation: async () => {},
};
}
let raw: string | null = null;
try {
const headerList = await headers();
@@ -1377,13 +1417,12 @@ export async function requireMfaManagementAuth(
});
if (!validated.ok) throw refusal(validated.reason);
// Parity with the cookie path: `requireFreshMfa` refuses an account with no
// second factor enrolled, because a step-up-gated action is meaningless there.
// The Bearer path holds the same line rather than becoming the softer route.
// Unreachable under `"if-enrolled"` — that mode returned above when the
// account had no factor, so arriving here with `freshFactor` true means the
// enrolment was already established.
if (freshFactor && !(await hasSecondFactorEnrolled(auth.user))) {
// Parity with the cookie path: `requireFreshMfa` refuses an account that holds
// no credential able to produce the proof, because a step-up-gated action is
// unreachable rather than merely refused there. The Bearer path asks the same
// question, with the same `proofSource`, rather than becoming the softer or
// the stricter route.
if (freshFactor && !(await canProveFreshFactor(auth.user, proofSource))) {
throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled");
}