mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
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,
|
||||
|
||||
Reference in new issue
Block a user