feat(auth): add a fresh-MFA step-up gate

Add requireFreshMfa(maxAgeSeconds) and requireCookieAuth alongside
requireAdmin. Both resolve through getSession() only, so a Bearer token can
never satisfy them — the same structural boundary requireAdmin relies on, not
a softenable runtime check. requireFreshMfa passes only for a cookie session
whose Session.mfaVerifiedAt is within the window and whose user has an active
second factor; otherwise it throws StepUpRequiredError (401 +
meta.errorCode "auth.stepup.required") so the client can launch a
re-verification flow.

createSession now accepts an optional mfaVerifiedAt stamp so a session minted
behind a completed second factor records its freshness.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 19:56:02 +02:00
1 parent 4d3a02cc1a
commit 3d3018b12e
3 files changed
+235

No files matched your search

+113
View File
@@ -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();
});
});
+116
View File
@@ -191,6 +191,21 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
},
{ 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<AuthContext> {
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<AuthContext> {
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<FreshMfaContext> {
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.
+6
View File
@@ -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<string> {
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,
},
});