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

@@ -1,7 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { user: { findFirst: vi.fn() } },
prisma: {
user: { findFirst: vi.fn() },
webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) },
},
}));
vi.mock("@/lib/auth/password", () => ({
verifyPassword: vi.fn().mockResolvedValue(true),
@@ -75,6 +78,8 @@ const BASE_USER = {
beforeEach(() => {
vi.clearAllMocks();
// Default: no registered security key. Individual tests override as needed.
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
});
describe("login MFA branch", () => {
@@ -99,6 +104,22 @@ describe("login MFA branch", () => {
expect(finishLogin).not.toHaveBeenCalled();
});
it("WebAuthn-only user (no TOTP) → returns mfaRequired with webauthn method", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...BASE_USER,
totpConfirmedAt: null,
} as never);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
const res = await POST(loginRequest());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta.mfaRequired).toBe(true);
expect(body.meta.methods).toEqual(["webauthn"]);
expect(finishLogin).not.toHaveBeenCalled();
});
it("non-MFA user → issues the session via finishLogin, no challenge", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...BASE_USER,