mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Introduce a dedicated /settings/security section that gathers every account-protection control in one place: TOTP enrolment (QR plus manual secret, confirm, recovery codes shown once with copy and download), WebAuthn security keys as a second factor (register, list with last-used, rename, step-up-gated removal), recovery-code count and regeneration, and primary passkey management. The second-factor WebAuthn path keeps its own credential store, separate from primary passkeys, and uses non-resident keys so an account can hold many. Relying-party resolution is shared with the passkey path. A status endpoint backs the hub; the step-up gate now recognises a registered security key as an active second factor so a WebAuthn-only account can still reach the gated actions.
127 lines
3.9 KiB
TypeScript
127 lines
3.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// Mirrors require-auth-bearer.test.ts mocking so the api-handler module loads
|
|
// without a live DB / request scope.
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
session: { findUnique: vi.fn() },
|
|
webauthnMfaCredential: { count: vi.fn() },
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/session", () => ({
|
|
getSession: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
|
|
vi.mock("@/lib/auth/audit", () => ({
|
|
auditLog: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
|
|
|
const headersGet = vi.fn<(name: string) => string | null>();
|
|
vi.mock("next/headers", () => ({
|
|
headers: vi.fn(async () => ({ get: headersGet })),
|
|
cookies: vi.fn(async () => ({
|
|
get: () => undefined,
|
|
set: () => {},
|
|
delete: () => {},
|
|
})),
|
|
}));
|
|
|
|
import {
|
|
requireFreshMfa,
|
|
StepUpRequiredError,
|
|
HttpError,
|
|
MFA_STEP_UP_MAX_AGE_SECONDS,
|
|
} from "../api-handler";
|
|
import { getSession } from "@/lib/auth/session";
|
|
import { prisma } from "@/lib/db";
|
|
|
|
const MFA_USER = {
|
|
id: "user-1",
|
|
role: "USER" as const,
|
|
username: "u",
|
|
totpConfirmedAt: new Date("2020-01-01"),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
function mockSession(user: unknown) {
|
|
vi.mocked(getSession).mockResolvedValue({
|
|
session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
|
|
user,
|
|
} as never);
|
|
}
|
|
|
|
describe("requireFreshMfa", () => {
|
|
it("passes for a cookie session verified within the window", async () => {
|
|
mockSession(MFA_USER);
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
mfaVerifiedAt: new Date(Date.now() - 60_000), // 1 min ago
|
|
} as never);
|
|
|
|
const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
|
expect(ctx.user.id).toBe("user-1");
|
|
expect(ctx.mfaVerifiedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("rejects a stale verification (outside the window)", async () => {
|
|
mockSession(MFA_USER);
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
mfaVerifiedAt: new Date(Date.now() - 10 * 60_000), // 10 min ago
|
|
} as never);
|
|
|
|
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
|
name: "StepUpRequiredError",
|
|
errorCode: "auth.stepup.required",
|
|
statusCode: 401,
|
|
});
|
|
});
|
|
|
|
it("rejects an absent verification stamp", async () => {
|
|
mockSession(MFA_USER);
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
mfaVerifiedAt: null,
|
|
} as never);
|
|
|
|
await expect(requireFreshMfa(5 * 60)).rejects.toBeInstanceOf(
|
|
StepUpRequiredError,
|
|
);
|
|
});
|
|
|
|
it("rejects a user with neither TOTP nor a security key", async () => {
|
|
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
|
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
|
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
|
errorCode: "auth.stepup.mfa_not_enrolled",
|
|
});
|
|
// Never even reaches the session-row read.
|
|
expect(prisma.session.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts a WebAuthn-only account with a fresh stamp", async () => {
|
|
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
|
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
|
|
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
|
mfaVerifiedAt: new Date(Date.now() - 60_000),
|
|
} as never);
|
|
|
|
const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
|
expect(ctx.user.id).toBe("user-1");
|
|
});
|
|
|
|
it("rejects when there is no cookie session — Bearer can never satisfy it", async () => {
|
|
// getSession is cookie-only; a Bearer header is irrelevant and never read.
|
|
headersGet.mockImplementation((n) =>
|
|
n.toLowerCase() === "authorization" ? "Bearer hlk_xyz" : null,
|
|
);
|
|
vi.mocked(getSession).mockResolvedValue(null as never);
|
|
|
|
await expect(requireFreshMfa(5 * 60)).rejects.toBeInstanceOf(HttpError);
|
|
expect(prisma.session.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
});
|