fix(auth): count a passkey sign-in as a satisfied second factor

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 21:50:02 +02:00
1 parent c8bef61344
commit 94c3cdea96
2 files changed
+41 -1

No files matched your search

@@ -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);
});
});
+22 -1
View File
@@ -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,