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

+463 -414
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -5584,6 +5584,7 @@
"passkeyReauth": {
"title": "Bestätige deine Identität",
"description": "Bestätige vor dem Hinzufügen eines neuen Passkeys eine vorhandene Anmeldemethode.",
"factorDescription": "Bestätige eine vorhandene Anmeldemethode, bevor du einen zweiten Faktor hinzufügst.",
"methodLabel": "Bestätigungsmethode",
"methods": {
"password": "Passwort",
@@ -5594,7 +5595,8 @@
"currentPassword": "Aktuelles Passwort",
"authenticatorCode": "Authenticator-Code",
"authenticatorPlaceholder": "Sechsstelliger Authenticator-Code",
"verifyAndAdd": "Bestätigen und hinzufügen"
"verifyAndAdd": "Bestätigen und hinzufügen",
"verifyAndContinue": "Bestätigen und fortfahren"
},
"passkeyName": "Name",
"noPasskeys": "Keine Passkeys registriert.",
+3 -1
View File
@@ -5584,6 +5584,7 @@
"passkeyReauth": {
"title": "Confirm it's you",
"description": "Verify an existing sign-in method before adding a new passkey.",
"factorDescription": "Verify an existing sign-in method before adding a second factor.",
"methodLabel": "Verification method",
"methods": {
"password": "Password",
@@ -5594,7 +5595,8 @@
"currentPassword": "Current password",
"authenticatorCode": "Authenticator code",
"authenticatorPlaceholder": "6-digit authenticator code",
"verifyAndAdd": "Verify and add"
"verifyAndAdd": "Verify and add",
"verifyAndContinue": "Verify and continue"
},
"passkeyName": "Name",
"noPasskeys": "No passkeys registered.",
+3 -1
View File
@@ -5584,6 +5584,7 @@
"passkeyReauth": {
"title": "Confirma tu identidad",
"description": "Verifica un método de inicio de sesión existente antes de añadir una nueva clave de acceso.",
"factorDescription": "Verifica un método de inicio de sesión existente antes de añadir un segundo factor.",
"methodLabel": "Método de verificación",
"methods": {
"password": "Contraseña",
@@ -5594,7 +5595,8 @@
"currentPassword": "Contraseña actual",
"authenticatorCode": "Código del autenticador",
"authenticatorPlaceholder": "Código de seis dígitos del autenticador",
"verifyAndAdd": "Verificar y añadir"
"verifyAndAdd": "Verificar y añadir",
"verifyAndContinue": "Verificar y continuar"
},
"passkeyName": "Nombre",
"noPasskeys": "No hay claves de acceso registradas.",
+3 -1
View File
@@ -5584,6 +5584,7 @@
"passkeyReauth": {
"title": "Confirmez votre identité",
"description": "Vérifiez une méthode de connexion existante avant d’ajouter une nouvelle clé d’accès.",
"factorDescription": "Vérifiez une méthode de connexion existante avant d'ajouter un second facteur.",
"methodLabel": "Méthode de vérification",
"methods": {
"password": "Mot de passe",
@@ -5594,7 +5595,8 @@
"currentPassword": "Mot de passe actuel",
"authenticatorCode": "Code d’authentification",
"authenticatorPlaceholder": "Code d’authentification à 6 chiffres",
"verifyAndAdd": "Vérifier et ajouter"
"verifyAndAdd": "Vérifier et ajouter",
"verifyAndContinue": "Vérifier et continuer"
},
"passkeyName": "Nom",
"noPasskeys": "Aucune clé d’accès enregistrée.",
+3 -1
View File
@@ -5584,6 +5584,7 @@
"passkeyReauth": {
"title": "Conferma la tua identità",
"description": "Verifica un metodo di accesso esistente prima di aggiungere una nuova passkey.",
"factorDescription": "Verifica un metodo di accesso esistente prima di aggiungere un secondo fattore.",
"methodLabel": "Metodo di verifica",
"methods": {
"password": "Password",
@@ -5594,7 +5595,8 @@
"currentPassword": "Password attuale",
"authenticatorCode": "Codice dell’app di autenticazione",
"authenticatorPlaceholder": "Codice a 6 cifre dell’app di autenticazione",
"verifyAndAdd": "Verifica e aggiungi"
"verifyAndAdd": "Verifica e aggiungi",
"verifyAndContinue": "Verifica e continua"
},
"passkeyName": "Nome",
"noPasskeys": "Nessuna passkey registrata.",
+3 -1
View File
@@ -5584,6 +5584,7 @@
"passkeyReauth": {
"title": "본인 확인",
"description": "새 패스키를 추가하기 전에 기존 로그인 수단으로 확인해 주세요.",
"factorDescription": "두 번째 인증 수단을 추가하기 전에 기존 로그인 방법을 확인하세요.",
"methodLabel": "확인 방법",
"methods": {
"password": "비밀번호",
@@ -5594,7 +5595,8 @@
"currentPassword": "현재 비밀번호",
"authenticatorCode": "인증 앱 코드",
"authenticatorPlaceholder": "6자리 인증 앱 코드",
"verifyAndAdd": "확인 후 추가"
"verifyAndAdd": "확인 후 추가",
"verifyAndContinue": "확인하고 계속"
},
"passkeyName": "이름",
"noPasskeys": "등록된 패스키가 없어요.",
+3 -1
View File
@@ -5584,6 +5584,7 @@
"passkeyReauth": {
"title": "Potwierdź swoją tożsamość",
"description": "Przed dodaniem nowego klucza dostępu zweryfikuj jedną z istniejących metod logowania.",
"factorDescription": "Potwierdź istniejącą metodę logowania, zanim dodasz drugi składnik.",
"methodLabel": "Metoda weryfikacji",
"methods": {
"password": "Hasło",
@@ -5594,7 +5595,8 @@
"currentPassword": "Aktualne hasło",
"authenticatorCode": "Kod z aplikacji uwierzytelniającej",
"authenticatorPlaceholder": "6-cyfrowy kod z aplikacji uwierzytelniającej",
"verifyAndAdd": "Zweryfikuj i dodaj"
"verifyAndAdd": "Zweryfikuj i dodaj",
"verifyAndContinue": "Potwierdź i kontynuuj"
},
"passkeyName": "Nazwa",
"noPasskeys": "Brak zarejestrowanych kluczy dostępu.",
@@ -0,0 +1,76 @@
/**
* Every route that checks a password or a second factor is a guessing oracle,
* and every one must be throttled and must record a refused guess.
*
* The passkey-enrollment re-proof verified a password and a TOTP code with
* neither: a stolen session could guess there as fast as it could send
* requests, and nothing in the audit log would show it. Behavioural tests pin
* each route that was fixed; this freezes the SET, so a new route that starts
* verifying a credential has to be listed here, and the listing asks the two
* questions at the moment it is added.
*
* Matching is on the verifier's name anywhere in the file, not on an import
* shape, so an aliased import still counts. It remains a tripwire: it proves a
* throttle and an audit call are present in the file, not that they sit on the
* right branch — the per-route tests do that.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const APP = join(process.cwd(), "src", "app");
const VERIFIER =
/\b(verifyPassword|verifyPasswordOrDummy|verifyMfaFactor|verifyTotp|verifyMfaAuthentication|verifyAuthentication|verifyExistingFactorProof|checkCookieEnrollmentProof)\b/;
const THROTTLE =
/\b(checkRateLimit|checkAuthSurfaceRateLimit|throttleReproof|checkCookieEnrollmentProof|reserveChallengeAttempt)\s*\(/;
const AUDIT =
/\b(auditLog|recordReproofFailure|checkCookieEnrollmentProof)\s*\(/;
/**
* Every file under src/app that verifies a credential. Adding one means
* answering, in its own tests, how a wrong guess is limited and recorded.
*/
const EXPECTED = [
"api/auth/login/route.ts",
"api/auth/me/mfa/disable/route.ts",
"api/auth/me/mfa/totp/confirm/route.ts",
"api/auth/me/mfa/totp/setup/route.ts",
"api/auth/me/mfa/webauthn/register/options/route.ts",
"api/auth/mfa/verify/route.ts",
"api/auth/mfa/webauthn/verify/route.ts",
"api/auth/passkey/login-verify/route.ts",
"api/auth/passkey/register-options/route.ts",
"api/auth/password/route.ts",
"api/auth/step-up/route.ts",
];
function verifyingFiles(): string[] {
return walkSourceFiles(APP, { floor: 500 })
.filter((p) => !p.includes("__tests__"))
.filter((p) => !/\.test\.tsx?$/.test(p))
.filter((p) => VERIFIER.test(readFileSync(join(APP, p), "utf8")));
}
describe("credential-verifying routes", () => {
const found = verifyingFiles();
it("finds the verifying routes at all", () => {
// An empty match agrees with nothing and would pass on a tree never read.
expect(found.length).toBeGreaterThanOrEqual(EXPECTED.length);
});
it("is exactly the listed set", () => {
expect(found).toEqual(EXPECTED);
});
it.each(EXPECTED)("%s is throttled and audits refusals", (rel) => {
const src = readFileSync(join(APP, rel), "utf8");
expect(src).toMatch(THROTTLE);
expect(src).toMatch(AUDIT);
});
});
+20 -1
View File
@@ -10,7 +10,14 @@
*
* Gated by `requireMfaManagementAuth`: a cookie session, or a Bearer token
* presenting a single-use step-up elevation minted against a re-proved factor.
* A token on its own can still never enrol MFA. The recovery-code batch is
* A token on its own can still never enrol MFA.
*
* A cookie session on its own cannot either. Enrolling hands the caller a
* 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
* issued at `/confirm` (after the factor is proven), not here, so an abandoned
* setup never persists codes.
*/
@@ -26,6 +33,7 @@ import { prisma } from "@/lib/db";
import { encrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { generateTotpSecret, buildOtpauthUri } from "@/lib/auth/mfa/totp";
import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof";
export const dynamic = "force-dynamic";
@@ -57,6 +65,17 @@ export const POST = apiHandler(async (req: Request) => {
throw new HttpError(409, "A second factor is already active");
}
if (auth.transport === "cookie") {
const refusal = await checkCookieEnrollmentProof({
user,
sessionId: auth.session.id,
request: req,
ipAddress: getClientIp(req),
stage: "totp_enroll",
});
if (refusal) return refusal;
}
// Rate limit and the already-active check have passed; the write is next.
await auth.commitElevation();
@@ -31,11 +31,16 @@ vi.mock("@/lib/auth/audit", () => ({
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/existing-factor-proof", () => ({
checkCookieEnrollmentProof: vi.fn(),
}));
import { POST as REGISTER_OPTIONS } from "../options/route";
import { POST as REGISTER_VERIFY } from "../verify/route";
import { requireMfaManagementAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof";
import {
createMfaRegistrationOptions,
verifyMfaRegistration,
@@ -63,8 +68,16 @@ beforeEach(() => {
commitElevation: vi.fn().mockResolvedValue(undefined),
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(checkCookieEnrollmentProof).mockResolvedValue(null);
});
function optionsRequest(): NextRequest {
return new NextRequest(
"http://localhost/api/auth/me/mfa/webauthn/register/options",
{ method: "POST" },
);
}
describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
it("returns ceremony options + challenge id", async () => {
vi.mocked(createMfaRegistrationOptions).mockResolvedValue({
@@ -72,7 +85,7 @@ describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
challengeId: "ch-1",
} as never);
const res = await REGISTER_OPTIONS();
const res = await REGISTER_OPTIONS(optionsRequest());
const body = (await res.json()) as {
data: { options: unknown; challengeId: string };
};
@@ -86,6 +99,40 @@ describe("POST /api/auth/me/mfa/webauthn/register/options", () => {
});
});
describe("security-key enrollment needs a fresh proof on the cookie arm", () => {
it("stops before any challenge exists when the session cannot show one", async () => {
vi.mocked(checkCookieEnrollmentProof).mockResolvedValue(
new Response(null, { status: 401 }),
);
const res = await REGISTER_OPTIONS(optionsRequest());
expect(res.status).toBe(401);
expect(createMfaRegistrationOptions).not.toHaveBeenCalled();
expect(checkCookieEnrollmentProof).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "sess-1",
stage: "security_key_enroll",
}),
);
});
it("leaves the Bearer arm to its elevation", async () => {
vi.mocked(requireMfaManagementAuth).mockResolvedValue({
transport: "bearer",
user: USER,
apiTokenId: "tok-1",
accessTokenHash: "h",
commitElevation: vi.fn().mockResolvedValue(undefined),
} as never);
vi.mocked(createMfaRegistrationOptions).mockResolvedValue({
options: {},
challengeId: "ch-2",
} as never);
const res = await REGISTER_OPTIONS(optionsRequest());
expect(res.status).toBe(200);
expect(checkCookieEnrollmentProof).not.toHaveBeenCalled();
});
});
describe("POST /api/auth/me/mfa/webauthn/register/verify", () => {
const credential = {
id: "cred-1",
@@ -5,16 +5,21 @@
* session or a Bearer token presenting a single-use step-up elevation; a token
* on its own can never enrol MFA. Returns the SimpleWebAuthn creation options +
* the server-issued challenge id to present back at /register/verify.
*
* 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.
*/
import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { checkCookieEnrollmentProof } from "@/lib/auth/existing-factor-proof";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { createMfaRegistrationOptions } from "@/lib/auth/mfa/webauthn";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async () => {
export const POST = apiHandler(async (req: Request) => {
const auth = await requireMfaManagementAuth();
const { user } = auth;
@@ -31,6 +36,17 @@ export const POST = apiHandler(async () => {
return res;
}
if (auth.transport === "cookie") {
const refusal = await checkCookieEnrollmentProof({
user,
sessionId: auth.session.id,
request: req,
ipAddress: getClientIp(req),
stage: "security_key_enroll",
});
if (refusal) return refusal;
}
await auth.commitElevation();
const { options, challengeId } = await createMfaRegistrationOptions(
@@ -2,16 +2,20 @@ import { NextRequest } from "next/server";
import {
createAuthenticationOptions,
createRegistrationOptions,
verifyAuthentication,
} from "@/lib/auth/passkey";
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
import {
createMfaAuthenticationOptions,
verifyMfaAuthentication,
} from "@/lib/auth/mfa/webauthn";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import { verifyPassword } from "@/lib/auth/password";
recordReproofFailure,
throttleReproof,
verifyExistingFactorProof,
} from "@/lib/auth/existing-factor-proof";
import { prisma } from "@/lib/db";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { stepUpMintSchema } from "@/lib/validations/step-up";
@@ -51,50 +55,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) return proofRequired();
let proved = false;
// 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.
const ip = getClientIp(request);
const limited = await throttleReproof(user.id, ip, "passkey_enroll");
if (limited) return limited;
if (parsed.data.method === "password") {
proved = Boolean(
user.passwordHash &&
(await verifyPassword(user.passwordHash, parsed.data.password)),
const outcome = await verifyExistingFactorProof(user, parsed.data);
if (!outcome.ok) {
await recordReproofFailure(
user.id,
ip,
"passkey_enroll",
parsed.data.method,
outcome.reason,
);
} else if (parsed.data.method === "totp") {
const result = await verifyMfaFactor(user, "totp", parsed.data.code);
proved = result.ok;
} else {
const expectedType =
parsed.data.method === "passkey"
? "authentication"
: "mfa_authentication";
const challenge = await prisma.authChallenge.findUnique({
where: { id: parsed.data.challengeId },
select: { userId: true, type: true },
});
if (challenge?.userId === user.id && challenge.type === expectedType) {
try {
if (parsed.data.method === "passkey") {
const result = await verifyAuthentication(
parsed.data.challengeId,
parsed.data.credential,
);
proved =
result.verification.verified && result.passkey.userId === user.id;
} else {
proved = await verifyMfaAuthentication(
parsed.data.challengeId,
user.id,
parsed.data.credential,
);
}
} catch {
proved = false;
}
}
return proofRequired();
}
if (!proved) return proofRequired();
// A strong existing factor refreshes the session's MFA stamp. Password proof
// deliberately clears it: registration is authorized by this single-use,
// session-bound challenge but cannot upgrade password-only authentication.
+17 -9
View File
@@ -14,7 +14,10 @@ import {
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import {
recordReproofFailure,
throttleReproof,
} from "@/lib/auth/existing-factor-proof";
import {
apiHandler,
requireFreshMfaIfEnrolled,
@@ -35,14 +38,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
// contract. Mirrors the step-up on MFA-disable + recovery-code regen.
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
const rl = await checkRateLimit(
`auth:password:${user.id}`,
5,
15 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
// The current-password check below is a guessing oracle, so it draws on the
// account's shared re-proof budget: a guess spent here is one the step-up
// mint and the enrollment proofs cannot spend again.
const ip = getClientIp(request);
const limited = await throttleReproof(user.id, ip, "password_change");
if (limited) return limited;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
@@ -62,6 +63,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
const currentValid = await verifyPassword(user.passwordHash, currentPassword);
if (!currentValid) {
await recordReproofFailure(
user.id,
ip,
"password_change",
"password",
"bad_password",
);
return apiError("Current password is incorrect", 401);
}
+18 -79
View File
@@ -40,16 +40,17 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
checkAuthSurfaceRateLimit,
checkRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import { verifyPassword } from "@/lib/auth/password";
import { verifyAuthentication } from "@/lib/auth/passkey";
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import {
REPROOF_LIMIT,
REPROOF_WINDOW_MS,
reproofBucket,
verifyExistingFactorProof,
} from "@/lib/auth/existing-factor-proof";
import {
mintStepUpElevation,
isFreshFactorMethod,
@@ -65,8 +66,8 @@ export const dynamic = "force-dynamic";
* against a user-supplied string — the same 5-per-15-minutes the password-change
* route uses, for the same reason.
*/
const MINT_LIMIT = 5;
const MINT_WINDOW_MS = 15 * 60 * 1000;
const MINT_LIMIT = REPROOF_LIMIT;
const MINT_WINDOW_MS = REPROOF_WINDOW_MS;
export const POST = apiHandler(async (request: NextRequest) => {
const auth = await requireBearerAuth();
@@ -91,8 +92,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
// The account's shared re-proof bucket: a guess spent here is a guess the
// web enrollment surfaces cannot spend again, and the other way round.
const userRl = await checkRateLimit(
`auth:step-up:${user.id}`,
reproofBucket(user.id),
MINT_LIMIT,
MINT_WINDOW_MS,
);
@@ -122,78 +125,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
const method: StepUpMethod = parsed.data.method;
let proved = false;
// The shared verifier: the same challenge ownership and ceremony checks, and
// the same confirmed-secret rule for TOTP, as every other re-proof surface.
// The replay guard and the accepted-step burn behave exactly as they do at
// login and at MFA-disable, so a code spent here cannot be replayed there.
const outcome = await verifyExistingFactorProof(user, parsed.data);
const proved = outcome.ok;
// Audit-only detail. Never reaches the response — see the file header.
let failure = "invalid";
/**
* Resolve a WebAuthn challenge and confirm it belongs to THIS account before
* any verification runs. Both ceremony helpers resolve a challenge by id
* alone, so without this a challenge minted for another account could be
* carried into the ceremony.
*/
const challengeBelongsToCaller = async (id: string): Promise<boolean> => {
const row = await prisma.authChallenge.findUnique({
where: { id },
select: { userId: true },
});
return Boolean(row && row.userId === user.id);
};
if (parsed.data.method === "password") {
if (!user.passwordHash) {
// An SSO-provisioned account has no password to re-prove. Same refusal as
// a wrong one: the account's credential shape is not a token holder's to
// enumerate. `/api/auth/step-up/options` is the honest discovery path —
// it 409s when there is no credential of the requested kind either.
failure = "no_password";
} else {
proved = await verifyPassword(user.passwordHash, parsed.data.password);
if (!proved) failure = "bad_password";
}
} else if (parsed.data.method === "totp") {
// The shared factor verifier, so the replay guard and the accepted-step
// burn behave exactly as they do at login and at MFA-disable. A code spent
// here cannot be replayed there.
const result = await verifyMfaFactor(user, "totp", parsed.data.code);
proved = result.ok;
if (!proved) failure = result.replay ? "totp_replay" : "bad_totp";
} else if (parsed.data.method === "webauthn") {
if (!(await challengeBelongsToCaller(parsed.data.challengeId))) {
failure = "foreign_challenge";
} else {
try {
// Scoped to the caller's own second-factor credentials by the verifier.
proved = await verifyMfaAuthentication(
parsed.data.challengeId,
user.id,
parsed.data.credential,
);
if (!proved) failure = "bad_assertion";
} catch {
// A malformed / expired challenge is a failed attempt, not a 500.
failure = "bad_assertion";
}
}
} else {
if (!(await challengeBelongsToCaller(parsed.data.challengeId))) {
failure = "foreign_challenge";
} else {
try {
const result = await verifyAuthentication(
parsed.data.challengeId,
parsed.data.credential,
);
// Both halves matter: a verified assertion against SOMEONE ELSE'S
// passkey proves possession of a factor, just not of this account's.
proved =
result.verification.verified && result.passkey.userId === user.id;
if (!proved) failure = "bad_assertion";
} catch {
failure = "bad_assertion";
}
}
}
const failure = outcome.ok ? "" : outcome.reason;
if (!proved) {
await auditLog("auth.stepup.mint.failed", {
@@ -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 && (
+284
View File
@@ -0,0 +1,284 @@
/**
* Re-proving a credential the account already holds.
*
* Several surfaces accept a fresh proof in the request body before they act:
* the Bearer step-up mint, the primary-passkey enrollment, TOTP enrollment and
* security-key enrollment. They used to verify it each in their own way, and
* the copies drifted — one checked who owned a WebAuthn challenge but not its
* type, one had no rate limit and wrote no audit row for a wrong password, so
* a stolen session could guess the password or a TOTP code there without
* limit and without trace. This module is the one verifier and the one
* throttle, so every surface refuses, counts and records a failed proof the
* same way.
*
* The throttle is ONE bucket per account shared by every surface: five proofs
* in fifteen minutes, the ceiling the step-up mint has used since it shipped
* (same key, so its counter carries straight over). A shared bucket means a
* guesser cannot multiply the budget by rotating between routes.
*/
import type { User } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { apiError } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { verifyPassword } from "@/lib/auth/password";
import { verifyAuthentication } from "@/lib/auth/passkey";
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor";
import {
stepUpMintSchema,
type StepUpMintRequest,
} from "@/lib/validations/step-up";
export type ExistingFactorProof = StepUpMintRequest;
export const REPROOF_LIMIT = 5;
export const REPROOF_WINDOW_MS = 15 * 60 * 1000;
/** The per-account bucket every re-proof surface draws from. */
export function reproofBucket(userId: string): string {
return `auth:step-up:${userId}`;
}
/**
* Charge one attempt to the account's re-proof bucket. Returns the 429 to send
* when the bucket is empty (and writes the audit row), or null to proceed.
* Call it only when a proof is actually about to be verified, so a request that
* needs no proof does not spend one.
*/
export async function throttleReproof(
userId: string,
ipAddress: string | null,
stage: string,
): Promise<Response | null> {
const rl = await checkRateLimit(
reproofBucket(userId),
REPROOF_LIMIT,
REPROOF_WINDOW_MS,
);
if (rl.allowed) return null;
await auditLog("auth.reproof.rate_limited", {
userId,
ipAddress,
details: { stage },
});
annotate({
action: { name: "auth.reproof.rate_limited" },
meta: { stage },
});
return apiError("Too many attempts. Please wait 15 minutes.", 429, {
headers: rateLimitHeaders(rl),
});
}
export type ProofOutcome = { ok: true } | { ok: false; reason: string };
/**
* Verify a proof against the account. Never throws for a bad proof: a
* malformed or expired WebAuthn challenge is a failed attempt, not a 500.
*
* A WebAuthn challenge must belong to this account AND be of the ceremony the
* method names, because both ceremony helpers resolve a challenge by id alone.
* A TOTP code is accepted only from a confirmed secret (`verifyMfaFactor`).
*/
export async function verifyExistingFactorProof(
user: Pick<
User,
| "id"
| "passwordHash"
| "totpSecretEncrypted"
| "totpLastStep"
| "totpConfirmedAt"
>,
proof: ExistingFactorProof,
): Promise<ProofOutcome> {
if (proof.method === "password") {
if (!user.passwordHash) return { ok: false, reason: "no_password" };
const ok = await verifyPassword(user.passwordHash, proof.password);
return ok ? { ok } : { ok: false, reason: "bad_password" };
}
if (proof.method === "totp") {
const result = await verifyMfaFactor(user, "totp", proof.code);
if (result.ok) return { ok: true };
return { ok: false, reason: result.replay ? "totp_replay" : "bad_totp" };
}
const expectedType =
proof.method === "passkey" ? "authentication" : "mfa_authentication";
const challenge = await prisma.authChallenge.findUnique({
where: { id: proof.challengeId },
select: { userId: true, type: true },
});
if (!challenge || challenge.userId !== user.id) {
return { ok: false, reason: "foreign_challenge" };
}
if (challenge.type !== expectedType) {
return { ok: false, reason: "wrong_ceremony" };
}
try {
if (proof.method === "passkey") {
const result = await verifyAuthentication(
proof.challengeId,
proof.credential,
);
// Both halves matter: a verified assertion against SOMEONE ELSE'S passkey
// proves possession of a factor, just not of this account's.
const ok =
result.verification.verified && result.passkey.userId === user.id;
return ok ? { ok } : { ok: false, reason: "bad_assertion" };
}
const ok = await verifyMfaAuthentication(
proof.challengeId,
user.id,
proof.credential,
);
return ok ? { ok } : { ok: false, reason: "bad_assertion" };
} catch {
return { ok: false, reason: "bad_assertion" };
}
}
/**
* Record a refused proof. The machine reason goes to the audit row and the
* wide event; the wire carries none of it.
*/
export async function recordReproofFailure(
userId: string,
ipAddress: string | null,
stage: string,
method: ExistingFactorProof["method"],
reason: string,
): Promise<void> {
await auditLog("auth.mfa.failed", {
userId,
ipAddress,
details: { stage, method, reason },
});
annotate({
action: { name: "auth.reproof.failed" },
meta: { stage, method, reason },
});
}
/** No proof was presented, and the session carries none recent enough. */
export const REPROOF_REQUIRED_CODE = "auth.reproof.required";
/** A proof was presented and did not verify. The session is untouched. */
export const REPROOF_FAILED_CODE = "auth.reproof.failed";
/**
* How recent a sign-in or a second-factor stamp must be to stand in for a
* proof in the body. The step-up window, so a fresh login is exactly as good
* here as it is on the fresh-factor routes.
*/
export const RECENT_PROOF_MAX_AGE_MS = 5 * 60 * 1000;
/**
* The cookie-path gate in front of ADDING a sign-in credential or second
* factor to an account (TOTP enrollment, security-key enrollment).
*
* A live session on its own is not enough. If it were, whoever held a stolen
* session could enroll their own authenticator, and from then on hold a factor
* that satisfies every step-up gate, registers a passkey and survives the
* owner changing the password. So the caller must show that the person at the
* keyboard can authenticate as the account owner right now, in one of three
* ways:
*
* - the session completed a second factor or passkey sign-in within the last
* five minutes (`Session.mfaVerifiedAt`);
* - the session itself was created by a sign-in within the last five
* minutes (`Session.createdAt`, which no later write touches) — this is
* also what lets an SSO-only account with nothing else to re-prove enroll
* right after signing in;
* - the body carries a proof: `{ method: "password", password }`,
* `{ method: "totp", code }` or a passkey / security-key assertion, the
* same shapes the step-up mint accepts.
*
* Returns the refusal to send, or null to proceed. Proofs in the body are
* throttled and audited through the shared re-proof bucket.
*/
export async function checkCookieEnrollmentProof(args: {
user: User;
sessionId: string;
request: Request;
ipAddress: string | null;
stage: string;
}): Promise<Response | null> {
const { user, sessionId, request, ipAddress, stage } = args;
const row = await prisma.session.findUnique({
where: { id: sessionId },
select: { mfaVerifiedAt: true, createdAt: true },
});
const now = Date.now();
const recent = (d: Date | null | undefined) =>
d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS;
if (recent(row?.mfaVerifiedAt) || recent(row?.createdAt)) {
annotate({ meta: { enrollment_proof: "recent_session" } });
return null;
}
const proof = await readProof(request);
if (proof === "absent") {
annotate({ action: { name: "auth.reproof.required" }, meta: { stage } });
return apiError("Confirm it is you before adding a sign-in method", 401, {
errorCode: REPROOF_REQUIRED_CODE,
});
}
if (proof === "invalid") {
return apiError("Invalid verification", 422, {
errorCode: REPROOF_REQUIRED_CODE,
});
}
const limited = await throttleReproof(user.id, ipAddress, stage);
if (limited) return limited;
const outcome = await verifyExistingFactorProof(user, proof);
if (!outcome.ok) {
await recordReproofFailure(
user.id,
ipAddress,
stage,
proof.method,
outcome.reason,
);
return apiError("Verification failed", 401, {
errorCode: REPROOF_FAILED_CODE,
});
}
annotate({ meta: { enrollment_proof: proof.method } });
return null;
}
async function readProof(
request: Request,
): Promise<ExistingFactorProof | "absent" | "invalid"> {
if (!request.headers.get("content-type")?.includes("application/json")) {
return "absent";
}
let raw: string;
try {
raw = await request.text();
} catch {
return "invalid";
}
if (raw.trim() === "") return "absent";
if (raw.length > 64 * 1024) return "invalid";
let body: unknown;
try {
body = JSON.parse(raw);
} catch {
return "invalid";
}
if (
body === null ||
(typeof body === "object" && Object.keys(body).length === 0)
) {
return "absent";
}
const parsed = stepUpMintSchema.safeParse(body);
return parsed.success ? parsed.data : "invalid";
}
+2
View File
@@ -60,6 +60,8 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"auth.refresh.invalid",
"auth.refresh.reuse",
"auth.refresh.revoked",
"auth.reproof.failed",
"auth.reproof.required",
"auth.scope.insufficient",
"auth.stepup.required",
"auth.token.expired",
+29 -5
View File
@@ -315,6 +315,17 @@ const successFlagResponse = z
const MFA_MANAGEMENT_AUTH_NOTE =
" Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused.";
const ENROLLMENT_PROOF_NOTE =
"\n\nAdding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey / security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401 `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the step-up mint.";
const enrollmentProofResponses = {
"401": {
description:
"No session or elevation; or, on the cookie path, `auth.reproof.required` (no recent sign-in and no proof in the body) or `auth.reproof.failed` (the proof did not verify).",
content: { "application/json": { schema: errorEnvelope } },
},
} as const;
// ── v1.30.34 step-up elevation (Bearer transport) ────────────────────
const stepUpOptionsResponse = z
@@ -805,7 +816,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Re-prove a factor and receive an opaque elevation that authorises exactly ONE second-factor-management call.\n\n" +
"WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable, recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT accepted here; an account that has lost its authenticator manages its second factor on the web.\n\n" +
"The elevation is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route is about to act, so a 429, a 422, or a wrong code does not burn it.\n\n" +
"Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per account (5 / 15 min) and per source address.\n\n" +
"Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per source address and per account (5 / 15 min); the per-account budget is shared with the web's credential-enrollment proofs. A `totp` proof counts only from an authenticator whose enrollment was confirmed: a code from a pending secret is refused like a wrong one.\n\n" +
"Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.\n\n" +
"The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their gate was cookie-only, which left an enrolled user unable to delete their own account from the app at all. They take the elevation the same way as the rest — fresh factor required, spent only when the erasure is about to run.",
requestBody: {
@@ -830,8 +841,13 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "Begin TOTP enrollment (cookie session or step-up elevation)",
description:
"Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm." +
MFA_MANAGEMENT_AUTH_NOTE,
"Generates and stores a pending (encrypted) TOTP secret and returns the otpauth URI + Base32 secret. MFA is not active until /confirm, and a pending secret is not a factor anywhere: a code from it is refused by every route that verifies one." +
MFA_MANAGEMENT_AUTH_NOTE +
ENROLLMENT_PROOF_NOTE,
requestBody: {
required: false,
content: { "application/json": { schema: stepUpMintSchema } },
},
responses: {
"200": {
description: "Pending secret created.",
@@ -846,6 +862,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...enrollmentProofResponses,
},
},
},
@@ -953,7 +970,12 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Begin registering a security key as a second factor",
description:
"Returns SimpleWebAuthn creation options + a challenge id." +
MFA_MANAGEMENT_AUTH_NOTE,
MFA_MANAGEMENT_AUTH_NOTE +
ENROLLMENT_PROOF_NOTE,
requestBody: {
required: false,
content: { "application/json": { schema: stepUpMintSchema } },
},
responses: {
"200": {
description: "Registration options issued.",
@@ -967,6 +989,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
...stdResponses,
...enrollmentProofResponses,
},
},
},
@@ -1922,7 +1945,8 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
'**Re-proof arm.** A body of exactly `{ method: "passkey" }` or `{ method: "webauthn" }` begins an assertion ceremony WITHOUT beginning enrollment, and answers with `reauth: true` alongside the options. Use it to obtain the assertion the second arm needs.\n\n' +
"**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued.\n\n" +
"Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the session second-factor-verified, while a PASSWORD proof deliberately CLEARS that stamp. Registering with a password is authorised by the single-use, session-bound challenge alone and must not silently upgrade password-only authentication into something the rest of the app reads as a second factor.\n\n" +
"Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller.",
"Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller. A `totp` proof counts only from an authenticator whose enrollment was confirmed.\n\n" +
"Each proof on the enrollment arm draws on the account's shared re-proof budget (five per fifteen minutes, the same budget as POST /api/auth/step-up), and every failed proof writes an `auth.mfa.failed` audit row. Once the budget is spent the answer is 429 until the window passes, even for a correct proof.",
requestBody: {
required: true,
content: {
+2
View File
@@ -55,6 +55,8 @@ export const stepUpMintSchema = z
"Re-prove a factor to mint a single-use, token-bound step-up elevation. `password` reaches the same routes a plain cookie session reaches; `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes (disable, recovery-code rotation, security-key removal), matching exactly the ceremonies for which the web stamps a session second-factor-verified.",
});
export type StepUpMintRequest = z.infer<typeof stepUpMintSchema>;
/** Which assertion ceremony to begin at `POST /api/auth/step-up/options`. */
export const stepUpOptionsSchema = z
.object({
@@ -214,6 +214,30 @@ describe("POST /api/auth/password (real Postgres)", () => {
);
});
it("audits a wrong current password and shares the re-proof ceiling", async () => {
const { userId } = await seedUserWithSession();
const prisma = getPrismaClient();
const { POST } = await import("@/app/api/auth/password/route");
const statuses: number[] = [];
for (let i = 0; i < 6; i++) {
const res = await POST(
makeRequest({
currentPassword: `WrongPasswordValue!${i}`,
newPassword: STRONG_NEW,
confirmPassword: STRONG_NEW,
}),
);
statuses.push(res.status);
}
expect(statuses).toEqual([401, 401, 401, 401, 401, 429]);
expect(
await prisma.auditLog.count({
where: { userId, action: "auth.mfa.failed" },
}),
).toBe(5);
});
it("returns 422 when the new password matches the current password", async () => {
await seedUserWithSession();
@@ -0,0 +1,265 @@
/**
* Adding a factor to an account needs a fresh proof, not just a live session.
*
* The chain this closes: a stolen browser session starts TOTP enrollment, reads
* a code from the secret it was just handed, and presents that code as "an
* existing second factor" — to mint a step-up, to register its own passkey, to
* pass the fresh-factor gates. Two rules break it, and both are pinned here
* against real Postgres:
*
* 1. a code from a secret that was never confirmed is not a factor anywhere;
* 2. the cookie arm of TOTP and security-key enrollment wants a sign-in or a
* second factor from the last five minutes, or a proof in the body.
*/
import { NextRequest } from "next/server";
import * as OTPAuth from "otpauth";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-enrollment-proof-32-bytes-minimum-1234567890";
const { hashToken } = await import("@/lib/auth/hmac");
const { hashPassword } = await import("@/lib/auth/password");
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const USER_ID = "user-enroll-proof";
const PASSWORD = "Correct horse battery staple!42";
const HOUR = 60 * 60 * 1000;
type RouteFn = (request: NextRequest) => Promise<Response>;
function post(path: string, body?: unknown): NextRequest {
const headers: Record<string, string> = {};
for (const name of ["authorization", "x-step-up"]) {
const value = headerJar.get(name);
if (value) headers[name] = value;
}
if (body !== undefined) headers["content-type"] = "application/json";
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}
async function totpSetup(body?: unknown) {
const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route");
return (POST as unknown as RouteFn)(
post("/api/auth/me/mfa/totp/setup", body),
);
}
async function securityKeyOptions(body?: unknown) {
const { POST } =
await import("@/app/api/auth/me/mfa/webauthn/register/options/route");
return (POST as unknown as RouteFn)(
post("/api/auth/me/mfa/webauthn/register/options", body),
);
}
async function stepUpMint(body: unknown) {
const { POST } = await import("@/app/api/auth/step-up/route");
return (POST as unknown as RouteFn)(post("/api/auth/step-up", body));
}
async function startSession(opts: {
createdAgoMs: number;
mfaVerifiedAgoMs?: number;
}): Promise<string> {
const now = Date.now();
const session = await getPrismaClient().session.create({
data: {
userId: USER_ID,
createdAt: new Date(now - opts.createdAgoMs),
expiresAt: new Date(now + HOUR),
mfaVerifiedAt:
opts.mfaVerifiedAgoMs === undefined
? null
: new Date(now - opts.mfaVerifiedAgoMs),
},
});
cookieJar.set("healthlog_session", session.id);
return session.id;
}
async function seedPendingTotp(): Promise<string> {
const { generateTotpSecret } = await import("@/lib/auth/mfa/totp");
const { encrypt } = await import("@/lib/crypto");
const secret = generateTotpSecret();
await getPrismaClient().user.update({
where: { id: USER_ID },
data: {
totpSecretEncrypted: encrypt(secret),
totpConfirmedAt: null,
totpLastStep: null,
},
});
return secret;
}
function codeFor(secret: string): string {
return new OTPAuth.TOTP({
issuer: "HealthLog",
label: "HealthLog",
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(secret),
}).generate({ timestamp: Date.now() });
}
async function errorCode(res: Response): Promise<string | undefined> {
const body = (await res.json()) as { meta?: { errorCode?: string } };
return body.meta?.errorCode;
}
async function storedSecret(): Promise<string | null> {
const user = await getPrismaClient().user.findUniqueOrThrow({
where: { id: USER_ID },
select: { totpSecretEncrypted: true },
});
return user.totpSecretEncrypted;
}
beforeEach(async () => {
vi.clearAllMocks();
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
process.env.APP_URL = "http://localhost:3000";
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "enroll-user",
email: "enroll@example.test",
role: "USER",
passwordHash: await hashPassword(PASSWORD),
},
});
});
describe("TOTP enrollment on the cookie arm", () => {
it("refuses a session that is neither fresh nor carrying a proof", async () => {
await startSession({ createdAgoMs: HOUR });
const res = await totpSetup();
expect(res.status).toBe(401);
expect(await errorCode(res)).toBe("auth.reproof.required");
expect(await storedSecret()).toBeNull();
});
it("enrolls with the account password in the body", async () => {
await startSession({ createdAgoMs: HOUR });
const res = await totpSetup({ method: "password", password: PASSWORD });
expect(res.status).toBe(200);
expect(await storedSecret()).not.toBeNull();
});
it("enrolls without a body right after signing in", async () => {
await startSession({ createdAgoMs: 30_000 });
const res = await totpSetup();
expect(res.status).toBe(200);
});
it("enrolls without a body after a recent second factor", async () => {
await startSession({ createdAgoMs: HOUR, mfaVerifiedAgoMs: 60_000 });
const res = await totpSetup();
expect(res.status).toBe(200);
});
it("refuses and audits a wrong password", async () => {
await startSession({ createdAgoMs: HOUR });
const res = await totpSetup({ method: "password", password: "nope" });
expect(res.status).toBe(401);
expect(await errorCode(res)).toBe("auth.reproof.failed");
expect(await storedSecret()).toBeNull();
expect(
await getPrismaClient().auditLog.count({
where: { userId: USER_ID, action: "auth.mfa.failed" },
}),
).toBe(1);
});
it("does not take a code from its own pending secret as the proof", async () => {
const secret = await seedPendingTotp();
await startSession({ createdAgoMs: HOUR });
const res = await totpSetup({ method: "totp", code: codeFor(secret) });
expect(res.status).toBe(401);
expect(await errorCode(res)).toBe("auth.reproof.failed");
});
it("cannot be guessed at past the shared re-proof ceiling", async () => {
await startSession({ createdAgoMs: HOUR });
const statuses: number[] = [];
for (let i = 0; i < 6; i++) {
statuses.push(
(await totpSetup({ method: "password", password: `guess-${i}` }))
.status,
);
}
expect(statuses).toEqual([401, 401, 401, 401, 401, 429]);
});
});
describe("security-key enrollment on the cookie arm", () => {
it("refuses a stale session without a proof and creates no challenge", async () => {
await startSession({ createdAgoMs: HOUR });
const res = await securityKeyOptions();
expect(res.status).toBe(401);
expect(await errorCode(res)).toBe("auth.reproof.required");
expect(await getPrismaClient().authChallenge.count()).toBe(0);
});
it("issues options once the password is re-proved", async () => {
await startSession({ createdAgoMs: HOUR });
const res = await securityKeyOptions({
method: "password",
password: PASSWORD,
});
expect(res.status).toBe(200);
expect(await getPrismaClient().authChallenge.count()).toBe(1);
});
});
describe("a pending TOTP secret on the Bearer arm", () => {
it("cannot mint a step-up elevation", async () => {
const secret = await seedPendingTotp();
const raw = `hlk_enrollproof${"0".repeat(64 - "enrollproof".length)}`;
await getPrismaClient().apiToken.create({
data: {
userId: USER_ID,
name: "phone",
tokenHash: hashToken(raw),
permissions: ["*"],
},
});
headerJar.set("authorization", `Bearer ${raw}`);
const res = await stepUpMint({ method: "totp", code: codeFor(secret) });
expect(res.status).toBe(401);
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
});
});
@@ -359,6 +359,61 @@ describe("passkey register-options existing-factor proof (real Postgres)", () =>
await expectNoCredential();
});
it("refuses a code from a TOTP secret that was never confirmed", async () => {
const secret = await seedTotp();
await getPrismaClient().user.update({
where: { id: USER_ID },
data: { totpConfirmedAt: null },
});
const response = await registerOptions({
method: "totp",
code: currentTotpCode(secret),
});
expect(response.status).toBe(401);
expect(await getPrismaClient().authChallenge.count()).toBe(0);
const session = await getPrismaClient().session.findFirstOrThrow({
where: { userId: USER_ID },
select: { mfaVerifiedAt: true },
});
expect(session.mfaVerifiedAt).toBeNull();
});
it("audits every wrong proof and stops guessing after five", async () => {
const statuses: number[] = [];
for (let i = 0; i < 6; i++) {
const response = await registerOptions({
method: "password",
password: `wrong-guess-${i}`,
});
statuses.push(response.status);
}
expect(statuses).toEqual([401, 401, 401, 401, 401, 429]);
const failed = await getPrismaClient().auditLog.findMany({
where: { userId: USER_ID, action: "auth.mfa.failed" },
select: { details: true },
});
expect(failed).toHaveLength(5);
const details = failed[0].details;
expect(
typeof details === "string" ? JSON.parse(details) : details,
).toMatchObject({
stage: "passkey_enroll",
method: "password",
reason: "bad_password",
});
// The bucket is spent, so even the right password waits it out.
const correct = await registerOptions({
method: "password",
password: PASSWORD,
});
expect(correct.status).toBe(429);
expect(await getPrismaClient().authChallenge.count()).toBe(0);
});
it.each([
["wildcard", ["*"]],
["narrow", ["medication:ingest"]],