From 94c3cdea96cc09d6e8dd860567605367bf100a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 21:50:02 +0200 Subject: [PATCH] fix(auth): count a passkey sign-in as a satisfied second factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A passwordless passkey login already clears user verification and is a phishing-resistant possession factor, but it never stamped the session, so a passkey user could not satisfy an MFA-enforcement policy or pass a step-up re-check. Stamp Session.mfaVerifiedAt on a primary passkey login so it counts as a completed second factor — equivalent to a password+TOTP sign-in. The password+TOTP path is unchanged. --- .../__tests__/native-token.test.ts | 19 +++++++++++++++ .../api/auth/passkey/login-verify/route.ts | 23 ++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts b/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts index c2b1b1b8c..4850a7840 100644 --- a/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts +++ b/src/app/api/auth/passkey/login-verify/__tests__/native-token.test.ts @@ -22,6 +22,14 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/auth/mfa-enrollment", () => ({ + syncMfaEnrollCookie: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/login-alert", () => ({ + recordSignInDevice: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), // v1.4.43 W13 M-4 — passkey/login-verify now uses the auth-surface @@ -53,6 +61,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { verifyAuthentication } from "@/lib/auth/passkey"; +import { createSession } from "@/lib/auth/session"; import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; const FAKE_USER = { @@ -133,4 +142,14 @@ describe("POST /api/auth/passkey/login-verify — native token issuance", () => expect(res.status).toBe(401); expect(prisma.apiToken.create).not.toHaveBeenCalled(); }); + + // v1.23 (M-review M1) — a primary passkey login is a satisfied second factor. + it("stamps mfaVerifiedAt on the session so the passkey satisfies step-up", async () => { + const res = await POST(makeRequest()); + expect(res.status).toBe(200); + // createSession(userId, onboardingPending, ip, ua, mfaVerifiedAt) — the + // 5th argument must be a Date for a primary passkey sign-in. + const args = vi.mocked(createSession).mock.calls[0]; + expect(args[4]).toBeInstanceOf(Date); + }); }); diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index c041ab07f..6f3f68374 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -1,5 +1,6 @@ import { verifyAuthentication } from "@/lib/auth/passkey"; import { createSession } from "@/lib/auth/session"; +import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; import { recordSignInDevice } from "@/lib/auth/login-alert"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; @@ -69,8 +70,28 @@ export const POST = apiHandler(async (request: NextRequest) => { // `hl_onboarding` cookie itself; pass the user's onboarding state // through so the proxy short-circuits the redirect before // hydration. + // + // v1.23 (M-review M1) — stamp `mfaVerifiedAt` on a primary passkey login. A + // discoverable passkey is a phishing-resistant possession factor that already + // cleared user verification (`userVerification: 'required'` on the ceremony), + // so a passwordless passkey sign-in is itself a satisfied second factor: it + // meets an MFA-enforcement policy AND passes `requireFreshMfa` step-up, + // exactly like a completed password+TOTP login. (The password+TOTP path is + // unchanged — it stamps the session at `/api/auth/mfa/verify`.) const ua = request.headers.get("user-agent"); - await createSession(user.id, user.onboardingCompletedAt == null, ip, ua); + await createSession( + user.id, + user.onboardingCompletedAt == null, + ip, + ua, + new Date(), + ); + + // v1.23 — sync the admin-enforced-MFA forced-enrollment hint cookie. + await syncMfaEnrollCookie(user.id, { + totpConfirmedAt: user.totpConfirmedAt, + mfaEnforced: user.mfaEnforced, + }); await auditLog("auth.login.passkey", { userId: user.id,