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

+5 -3
View File
@@ -16,8 +16,10 @@
* factor that later satisfies every step-up gate, so a stolen session must not
* be able to mint one: the cookie arm also passes `checkCookieEnrollmentProof`
* — a sign-in or second factor inside the last five minutes, or a password /
* factor proof in the body (the step-up mint's shapes). The Bearer arm already
* carries that proof in its elevation. The recovery-code batch is
* factor proof in the body (the step-up mint's shapes); on an account that
* already has a second factor, only that factor or a passkey. The Bearer arm
* carries the proof in its elevation, under the same rule: on an account with
* a second factor the elevation must come from one. The recovery-code batch is
* issued at `/confirm` (after the factor is proven), not here, so an abandoned
* setup never persists codes.
*/
@@ -41,7 +43,7 @@ const SETUP_RATE_LIMIT = 5;
const SETUP_WINDOW_MS = 15 * 60 * 1000;
export const POST = apiHandler(async (req: Request) => {
const auth = await requireMfaManagementAuth();
const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true });
const { user } = auth;
const rl = await checkRateLimit(
@@ -8,7 +8,10 @@
*
* The cookie arm additionally passes `checkCookieEnrollmentProof`: a session
* alone cannot add a security key, for the same reason it cannot enroll TOTP —
* the new key would satisfy every step-up gate from then on.
* the new key would satisfy every step-up gate from then on. On an account that
* already has a second factor, both arms want that factor (or a passkey): a
* password, or a session that skipped the factor on a remembered browser, does
* not add a key.
*/
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
@@ -20,7 +23,7 @@ import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (req: Request) => {
const auth = await requireMfaManagementAuth();
const auth = await requireMfaManagementAuth({ freshFactorIfEnrolled: true });
const { user } = auth;
const rl = await checkRateLimit(
@@ -5,10 +5,16 @@ import {
} from "@/lib/auth/passkey";
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
import {
isSecondFactorProof,
recordReproofFailure,
refundReproof,
throttleReproof,
verifyExistingFactorProof,
} from "@/lib/auth/existing-factor-proof";
import {
hasSecondFactorEnrolled,
recentProofMethods,
} from "@/lib/auth/second-factor";
import { prisma } from "@/lib/db";
import {
apiError,
@@ -55,6 +61,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) return proofRequired();
// On an account with a second factor a password does not add a sign-in
// credential, exactly as it does not satisfy the second factor at sign-in:
// a passkey registered on the strength of the password alone would from then
// on sign in without the second factor at all. Refused before a guess is
// spent, with the proofs that would work.
if (
!isSecondFactorProof(parsed.data.method) &&
(await hasSecondFactorEnrolled(user))
) {
annotate({
action: { name: "auth.reproof.method_refused" },
meta: { stage: "passkey_enroll", method: parsed.data.method },
});
return apiError("Confirm with your second factor or a passkey", 422, {
errorCode: "auth.reproof.too_weak",
methods: await recentProofMethods(user, true),
});
}
// Every proof counts against the account's shared re-proof bucket and every
// refusal is audited. Without both, a stolen session could guess the account
// password or a TOTP code here as fast as it could send requests.
@@ -73,6 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
return proofRequired();
}
await refundReproof(user.id);
// A strong existing factor refreshes the session's MFA stamp. Password proof
// deliberately clears it: registration is authorized by this single-use,
@@ -50,6 +50,7 @@ vi.mock("@/lib/auth/password", () => ({
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
refundRateLimit: vi.fn(),
}));
vi.mock("@/lib/i18n/server-locale", () => ({
@@ -77,7 +78,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
import { verifyPassword, checkPasswordStrength } from "@/lib/auth/password";
import {
requireFreshMfaIfEnrolled,
@@ -146,6 +147,9 @@ describe("POST /api/auth/password — MFA step-up (v1.25)", () => {
expect(res.status).toBe(200);
expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number));
expect(prisma.user.update).toHaveBeenCalled();
// The right current password was not a guess: the shared re-proof
// bucket gets its slot back.
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:user-1");
});
});
+5
View File
@@ -16,6 +16,7 @@ import {
} from "@/lib/api-response";
import {
recordReproofFailure,
refundReproof,
throttleReproof,
} from "@/lib/auth/existing-factor-proof";
import {
@@ -72,6 +73,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
return apiError("Current password is incorrect", 401);
}
// The current password was right, so this attempt was not a guess. Give the
// shared re-proof budget its slot back; otherwise a password change followed
// by a few confirmations elsewhere runs the owner out of attempts.
await refundReproof(user.id);
if (currentPassword === newPassword) {
return apiError("New password must differ from current password", 422);
+3
View File
@@ -52,6 +52,7 @@ import { annotate } from "@/lib/logging/context";
import {
REPROOF_FAILED_CODE,
recordReproofFailure,
refundReproof,
throttleReproof,
verifyExistingFactorProof,
} from "@/lib/auth/existing-factor-proof";
@@ -110,6 +111,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
await refundReproof(user.id);
const now = new Date();
await prisma.session.update({
where: { id: session.id },