diff --git a/src/lib/__tests__/require-fresh-mfa.test.ts b/src/lib/__tests__/require-fresh-mfa.test.ts new file mode 100644 index 000000000..8093ab688 --- /dev/null +++ b/src/lib/__tests__/require-fresh-mfa.test.ts @@ -0,0 +1,113 @@ +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() }, + }, +})); + +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 without an active second factor", async () => { + mockSession({ ...MFA_USER, totpConfirmedAt: null }); + 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("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(); + }); +}); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 4733b9ac2..d0761558f 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -191,6 +191,21 @@ export function apiHandler Promise>( }, { status: 403 }, ); + } else if (error instanceof StepUpRequiredError) { + // v1.23 — step-up gate not satisfied. Same 401 + meta.errorCode + // envelope shape as the assistant/consent gates so the client can + // branch on the stable code and launch a re-verification flow. + // Checked before the generic HttpError branch because + // StepUpRequiredError extends it. + evt.setError(error); + response = NextResponse.json( + { + data: null, + error: error.message, + meta: { errorCode: error.errorCode }, + }, + { status: error.statusCode }, + ); } else if (error instanceof HttpError) { evt.setError(error); response = NextResponse.json( @@ -441,6 +456,107 @@ export async function requireAdmin(): Promise { return sessionData; } +/** + * v1.23 — require a cookie-backed session, refusing Bearer tokens. + * + * Cookie-only by the same structural argument as `requireAdmin`: it resolves + * the session via `getSession()` (which reads only the session cookie) and + * never falls through to the Bearer branch. The second-factor management + * surfaces (TOTP enroll / confirm / disable / recovery-code regenerate) use + * this so an API token — even a wildcard one — can never enrol or tear down + * MFA on the account it belongs to. MFA management is a browser-only action. + */ +export async function requireCookieAuth(): Promise { + const sessionData = await getSession(); + if (!sessionData) throw new HttpError(401, "Not authenticated"); + + const evt = getEvent(); + if (evt) { + evt.setAuth({ + user_id: sessionData.user.id, + user_role: sessionData.user.role, + auth_method: "session", + }); + } + return sessionData; +} + +/** + * Error thrown when a step-up gate is not satisfied. Carries `errorCode` so + * the route can surface a stable machine code (`auth.stepup.required`) the + * client branches on to launch a re-verification flow rather than parsing + * prose. + */ +export class StepUpRequiredError extends HttpError { + constructor( + public errorCode: string = "auth.stepup.required", + message = "Recent second-factor verification required", + ) { + super(401, message); + this.name = "StepUpRequiredError"; + } +} + +/** + * Default step-up freshness window (5 minutes) for sensitive mutations. + * Within the 5–15 min band OWASP recommends; tight end because the gated + * actions (disable MFA, regenerate codes, and later key rotation / export) + * are destructive. + */ +export const MFA_STEP_UP_MAX_AGE_SECONDS = 5 * 60; + +export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date }; + +/** + * v1.23 — step-up gate. Passes only for a COOKIE session whose + * `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user has an + * active second factor (`totpConfirmedAt`). Throws `StepUpRequiredError` + * (401, `errorCode: "auth.stepup.required"`) otherwise. + * + * Bearer tokens can NEVER satisfy this — exactly like `requireAdmin`, the + * resolution path is `getSession()` (cookie-only) and there is no Bearer + * fall-through. A token transport carries no `mfaVerifiedAt` and cannot + * acquire one, so the boundary is structural, not a softenable runtime check. + * + * Consumed in Phase M by MFA disable + recovery-code regeneration; later + * waves gate account deletion, key rotation, and passphrase export on it. + */ +export async function requireFreshMfa( + maxAgeSeconds: number, +): Promise { + const sessionData = await getSession(); + if (!sessionData) throw new HttpError(401, "Not authenticated"); + + const evt = getEvent(); + if (evt) { + evt.setAuth({ + user_id: sessionData.user.id, + user_role: sessionData.user.role, + auth_method: "session", + }); + } + + // The user must actually have a second factor active. A single-factor + // account cannot produce a fresh-MFA proof, so step-up-gated actions are + // unreachable for it by design (the management UI gates enrolment first). + if (!sessionData.user.totpConfirmedAt) { + throw new StepUpRequiredError("auth.stepup.mfa_not_enrolled"); + } + + // Read the freshness stamp off the live session row — `getSession`'s + // projection intentionally omits it. + const row = await prisma.session.findUnique({ + where: { id: sessionData.session.id }, + select: { mfaVerifiedAt: true }, + }); + const verifiedAt = row?.mfaVerifiedAt ?? null; + if (!verifiedAt || verifiedAt.getTime() < Date.now() - maxAgeSeconds * 1000) { + throw new StepUpRequiredError(); + } + + return { ...sessionData, mfaVerifiedAt: verifiedAt }; +} + /** * Report unhandled errors to GlitchTip (fire-and-forget). * Uses dynamic import to avoid circular dependencies and startup cost. diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 34de657f2..7a2c73e77 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -58,6 +58,11 @@ export async function createSession( onboardingPending: boolean, ipAddress?: string | null, userAgent?: string | null, + // v1.23 — set when this session was minted behind a completed second + // factor (the `/api/auth/mfa/verify` path) or a fresh factor re-confirm. + // `requireFreshMfa` reads it for step-up freshness; a null value means + // "single-factor session" and never satisfies step-up. + mfaVerifiedAt?: Date | null, ): Promise { const session = await prisma.session.create({ data: { @@ -65,6 +70,7 @@ export async function createSession( expiresAt: new Date(Date.now() + SESSION_MAX_AGE_MS), ipAddress: ipAddress ?? null, userAgent: userAgent ?? null, + mfaVerifiedAt: mfaVerifiedAt ?? null, }, });