mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
4d3a02cc1a
commit
3d3018b12e
3 files changed
+235
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user