feat(auth): complete login with a security-key second factor

When a password is accepted for an account with a second factor, the
login response carries a single-use ticket instead of a session. The
login page now renders a second step: a TOTP or recovery code, or a
WebAuthn security-key assertion scoped to the account's registered keys.
A WebAuthn-only account (no TOTP) is recognised at the password step and
offered the security-key path.

The security-key verify endpoints validate the open ticket, verify the
assertion against the user's second-factor credentials, and only then
mint the session through the shared login tail, so no session exists
before the factor passes. The login page also arms passkey conditional-UI
autofill on the username field.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:40:45 +02:00
1 parent 402ea90d45
commit 86ac1e1d12
9 files changed
+879 -119

No files matched your search

@@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({
user: { findFirst: vi.fn() },
apiToken: { create: vi.fn() },
refreshToken: { create: vi.fn() },
// v1.23 — login checks for a registered second-factor security key.
webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) },
},
}));
@@ -97,6 +99,7 @@ function makeRequest(headers: Record<string, string> = {}): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
vi.mocked(prisma.refreshToken.create).mockResolvedValue({
+18 -5
View File
@@ -96,17 +96,30 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
// v1.23 — second-factor gate. The password is correct, but an account with
// a confirmed TOTP factor is NOT yet authenticated: no `Session` row and no
// token bundle is minted here. Instead a single-use, ~5-minute MFA ticket is
// a confirmed second factor (a TOTP secret and/or a registered WebAuthn
// security key) is NOT yet authenticated: no `Session` row and no token
// bundle is minted here. Instead a single-use, ~5-minute MFA ticket is
// returned (the partial state lives in the ticket, never in a half-built
// session) and the client completes the login at `/api/auth/mfa/verify`.
// session) and the client completes the login at `/api/auth/mfa/verify`
// (TOTP / recovery) or `/api/auth/mfa/webauthn/verify` (security key).
//
// Enumeration note: `meta.mfaRequired` is only ever returned AFTER a valid
// password, and the invalid-credentials response above is identical
// regardless of MFA state — an attacker without the password cannot learn
// whether an account has MFA.
if (user.totpConfirmedAt) {
const hasTotp = Boolean(user.totpConfirmedAt);
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
where: { userId: user.id },
});
const hasWebauthn = webauthnKeyCount > 0;
if (hasTotp || hasWebauthn) {
const challenge = await createMfaChallenge(user.id, "login");
// Recovery codes are only ever issued alongside TOTP enrollment, so the
// recovery method is offered exactly when TOTP is active.
const methods: ("totp" | "recovery" | "webauthn")[] = [];
if (hasTotp) methods.push("totp", "recovery");
if (hasWebauthn) methods.push("webauthn");
await auditLog("auth.mfa.challenge", {
userId: user.id,
ipAddress: ip,
@@ -123,7 +136,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
meta: {
mfaRequired: true,
mfaTicket: challenge.ticket,
methods: ["totp", "recovery"],
methods,
},
},
{ status: 200 },
@@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/auth/mfa/challenge", () => ({
loadActiveChallenge: vi.fn(),
recordChallengeFailure: vi.fn().mockResolvedValue({ exhausted: false }),
claimChallenge: vi.fn(),
}));
vi.mock("@/lib/auth/mfa/webauthn", () => ({
verifyMfaAuthentication: vi.fn(),
}));
vi.mock("@/lib/auth/login-response", () => ({
finishLogin: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import {
loadActiveChallenge,
claimChallenge,
recordChallengeFailure,
} from "@/lib/auth/mfa/challenge";
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
import { finishLogin } from "@/lib/auth/login-response";
function makeRequest(body: Record<string, unknown>): NextRequest {
return new NextRequest("http://localhost/api/auth/mfa/webauthn/verify", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const VALID_BODY = {
mfaTicket: "ticket-1",
challengeId: "ch-1",
credential: {
id: "cred-1",
rawId: "cred-1",
type: "public-key",
response: {},
},
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
ip: "203.0.113.1",
} as never);
vi.mocked(loadActiveChallenge).mockResolvedValue({
id: "mfa-1",
userId: "user-1",
kind: "login",
attempts: 0,
expiresAt: new Date(Date.now() + 60_000),
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user-1",
username: "u",
onboardingCompletedAt: new Date(),
} as never);
vi.mocked(recordChallengeFailure).mockResolvedValue({
exhausted: false,
attempts: 1,
} as never);
vi.mocked(finishLogin).mockResolvedValue(
new Response(JSON.stringify({ data: { user: { id: "user-1" } } }), {
status: 200,
}),
);
});
describe("POST /api/auth/mfa/webauthn/verify", () => {
it("finishes login when the assertion verifies and the ticket is claimed", async () => {
vi.mocked(verifyMfaAuthentication).mockResolvedValue(true as never);
vi.mocked(claimChallenge).mockResolvedValue(true as never);
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(200);
expect(verifyMfaAuthentication).toHaveBeenCalledWith(
"ch-1",
"user-1",
VALID_BODY.credential,
);
expect(claimChallenge).toHaveBeenCalledWith("mfa-1");
expect(finishLogin).toHaveBeenCalledWith(
expect.objectContaining({
mfaVerified: true,
source: "mfa.webauthn.verify",
}),
);
});
it("rejects with 401 and burns an attempt when the assertion fails", async () => {
vi.mocked(verifyMfaAuthentication).mockResolvedValue(false as never);
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(401);
expect(recordChallengeFailure).toHaveBeenCalledWith("mfa-1");
expect(claimChallenge).not.toHaveBeenCalled();
expect(finishLogin).not.toHaveBeenCalled();
});
it("rejects with 401 for an unknown / expired ticket", async () => {
vi.mocked(loadActiveChallenge).mockResolvedValue(null as never);
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(401);
expect(verifyMfaAuthentication).not.toHaveBeenCalled();
});
it("does not mint a session if the ticket claim is lost (concurrent)", async () => {
vi.mocked(verifyMfaAuthentication).mockResolvedValue(true as never);
vi.mocked(claimChallenge).mockResolvedValue(false as never);
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(401);
expect(finishLogin).not.toHaveBeenCalled();
});
it("returns 429 when rate limited", async () => {
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
ip: "203.0.113.1",
} as never);
const res = await POST(makeRequest(VALID_BODY));
expect(res.status).toBe(429);
});
});
@@ -0,0 +1,70 @@
/**
* POST /api/auth/mfa/webauthn/verify/options
*
* Begin a mid-login second-factor security-key assertion. The caller presents
* the opaque `mfaTicket` from the `meta.mfaRequired` login response; the server
* resolves the password-identified user and returns assertion options scoped to
* that user's registered MFA security keys (never an empty allow-list — a
* non-resident credential is not discoverable).
*
* Anonymous surface (no session yet), rate-limited per IP. A single generic
* 401 covers every unknown / expired / consumed ticket so a guesser learns
* nothing.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { loadActiveChallenge } from "@/lib/auth/mfa/challenge";
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
import { mfaWebauthnLoginOptionsSchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) => {
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:mfa-verify",
10,
15 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Too many attempts. Please try again later.", 429, {
headers: rateLimitHeaders(rl),
});
}
await ensureDbCompatibility();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = mfaWebauthnLoginOptionsSchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const challenge = await loadActiveChallenge(parsed.data.mfaTicket);
if (!challenge) {
annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } });
return apiError("Invalid or expired challenge", 401);
}
const result = await createMfaAuthenticationOptions(challenge.userId);
if (!result) {
// No registered security key for this account — the client should fall
// back to a TOTP / recovery code.
annotate({ action: { name: "auth.mfa.webauthn.no_key" } });
return apiError("No security key registered", 409);
}
annotate({ action: { name: "auth.mfa.webauthn.verify-options" } });
return apiSuccess({
options: result.options,
challengeId: result.challengeId,
});
});
@@ -0,0 +1,127 @@
/**
* POST /api/auth/mfa/webauthn/verify
*
* Complete a mid-login second-factor login challenge with a WebAuthn security
* key. The caller presents the login `mfaTicket` plus the assertion from
* /verify/options. On success the SAME session/token bundle the password path
* issues is returned (web → cookie; native → access+refresh), with the session
* stamped `mfaVerifiedAt`.
*
* Security invariants mirror /api/auth/mfa/verify (the TOTP path):
* - No session/token exists until the assertion verifies — the partial state
* lives in the ticket, never in a half-built session.
* - The ticket is claimed once, atomically, only AFTER the assertion verifies.
* - A failed assertion increments the ticket attempt counter and burns it at
* the cap. Anonymous surface is rate-limited per IP on top of the cap.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { finishLogin } from "@/lib/auth/login-response";
import {
loadActiveChallenge,
recordChallengeFailure,
claimChallenge,
} from "@/lib/auth/mfa/challenge";
import { verifyMfaAuthentication } from "@/lib/auth/mfa/webauthn";
import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) => {
const rl = await checkAuthSurfaceRateLimit(
request,
"auth:mfa-verify",
10,
15 * 60 * 1000,
);
const ip = rl.ip ?? "unknown";
if (!rl.allowed) {
return apiError("Too many attempts. Please try again later.", 429, {
headers: rateLimitHeaders(rl),
});
}
await ensureDbCompatibility();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = mfaWebauthnLoginVerifySchema.safeParse(body);
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const { mfaTicket, challengeId, credential } = parsed.data;
const challenge = await loadActiveChallenge(mfaTicket);
if (!challenge) {
annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } });
return apiError("Invalid or expired challenge", 401);
}
const user = await prisma.user.findUnique({
where: { id: challenge.userId },
select: { id: true, username: true, onboardingCompletedAt: true },
});
if (!user) {
await recordChallengeFailure(challenge.id);
return apiError("Invalid or expired challenge", 401);
}
let verified = false;
try {
verified = await verifyMfaAuthentication(challengeId, user.id, credential);
} catch {
// A malformed / expired WebAuthn challenge is a failed attempt, not a 500.
verified = false;
}
if (!verified) {
const { exhausted } = await recordChallengeFailure(challenge.id);
await auditLog("auth.mfa.failed", {
userId: user.id,
ipAddress: ip,
details: { stage: "login", method: "webauthn", exhausted },
});
annotate({
action: { name: "auth.mfa.failed" },
meta: { method: "webauthn", exhausted },
});
return apiError("Security key verification failed", 401);
}
// Assertion verified — claim the ticket atomically. Only the single winning
// caller proceeds to mint a session.
const claimed = await claimChallenge(challenge.id);
if (!claimed) {
annotate({ action: { name: "auth.mfa.verify.claim_lost" } });
return apiError("Invalid or expired challenge", 401);
}
await auditLog("auth.mfa.verified", {
userId: user.id,
ipAddress: ip,
details: { method: "webauthn" },
});
annotate({
action: { name: "auth.mfa.verified" },
meta: { method: "webauthn" },
});
const ua = request.headers.get("user-agent");
return finishLogin({
user,
request,
ip,
userAgent: ua,
source: "mfa.webauthn.verify",
mfaVerified: true,
});
});