Files
HealthLog/src/lib/__tests__/require-recent-proof.test.ts
T
Marc-André Bombeck 0692017f34 fix(auth): adding a credential beside a second factor needs that factor
On an account with a second factor, TOTP setup, security-key registration
and passkey registration no longer accept the password, a password re-proof
or a session younger than five minutes. A remembered browser skips the
second factor at sign-in and still creates a fresh session, so the password
alone could add a key that then satisfies every gate the factor protected.
Only a TOTP code, a security key, a passkey or a completed second factor
within five minutes opens enrollment there; the Bearer arm of TOTP setup and
security-key registration asks the same of its elevation. Accounts without
a second factor keep the previous rules.

The refusal names the proofs the account can give, and the enrollment
dialogs offer exactly those. A proof that verifies now gives its attempt
back to the shared re-proof budget, including the current password on a
password change.
2026-09-26 22:16:44 +02:00

267 lines
8.7 KiB
TypeScript

/**
* `requireRecentProof` / `assertRecentCookieProof` — the fresh-proof gate in
* front of whole-record exports, share links, token minting and the admin data
* actions.
*
* Cookie: an account with a second factor needs `mfaVerifiedAt` inside five
* minutes and nothing else; one without accepts a recent sign-in, second-factor
* stamp or password re-proof. Bearer: the rule the route names.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
session: { findUnique: vi.fn() },
apiToken: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
passkey: { count: vi.fn() },
stepUpElevation: { findUnique: vi.fn() },
$queryRaw: vi.fn(),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn(() => "hash") }));
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 {
requireMfaManagementAuth,
requireRecentProof,
StepUpRequiredError,
} from "../api-handler";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { headers } from "next/headers";
import { auditLog } from "@/lib/auth/audit";
const MFA_USER = {
id: "user-1",
role: "USER" as const,
username: "u",
totpConfirmedAt: new Date("2020-01-01"),
passwordHash: "argon",
};
const PLAIN_USER = { ...MFA_USER, totpConfirmedAt: null };
const NOW = () => new Date();
const OLD = () => new Date(Date.now() - 10 * 60 * 1000);
beforeEach(() => {
vi.resetAllMocks();
headersGet.mockReturnValue(null);
vi.mocked(headers).mockImplementation(
async () => ({ get: headersGet }) as never,
);
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
});
function cookie(
user: unknown,
stamps: {
mfaVerifiedAt?: Date | null;
createdAt?: Date;
reproofAt?: Date | null;
},
) {
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 1e6) },
user,
} as never);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
mfaVerifiedAt: stamps.mfaVerifiedAt ?? null,
createdAt: stamps.createdAt ?? OLD(),
reproofAt: stamps.reproofAt ?? null,
} as never);
}
function bearer(user: unknown, elevation: string | null = null) {
vi.mocked(getSession).mockResolvedValue(null as never);
headersGet.mockImplementation((n) => {
const name = n.toLowerCase();
if (name === "authorization") return "Bearer hlk_xyz";
if (name === "x-step-up") return elevation;
return null;
});
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(user as never);
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
}
async function refusal(p: Promise<unknown>): Promise<StepUpRequiredError> {
try {
await p;
} catch (err) {
expect(err).toBeInstanceOf(StepUpRequiredError);
return err as StepUpRequiredError;
}
throw new Error("expected a refusal");
}
describe("cookie session, account without a second factor", () => {
it("passes on a sign-in inside five minutes", async () => {
cookie(PLAIN_USER, { createdAt: NOW() });
await expect(
requireRecentProof({ bearer: "elevation" }),
).resolves.toBeTruthy();
});
it("passes on a password re-proof inside five minutes", async () => {
cookie(PLAIN_USER, { reproofAt: NOW() });
await expect(
requireRecentProof({ bearer: "elevation" }),
).resolves.toBeTruthy();
});
it("refuses an old session and names the password as the way through", async () => {
cookie(PLAIN_USER, {});
const err = await refusal(requireRecentProof({ bearer: "token" }));
expect(err.errorCode).toBe("auth.reproof.required");
expect(err.extraMeta).toEqual({ methods: ["password"] });
});
it("offers a passkey when the account holds one", async () => {
cookie({ ...PLAIN_USER, passwordHash: null }, {});
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
const err = await refusal(requireRecentProof({ bearer: "token" }));
expect(err.extraMeta).toEqual({ methods: ["passkey"] });
});
});
describe("cookie session, account with a second factor", () => {
it("passes on a second factor inside five minutes", async () => {
cookie(MFA_USER, { mfaVerifiedAt: NOW() });
await expect(
requireRecentProof({ bearer: "elevation" }),
).resolves.toBeTruthy();
});
it("a password re-proof does not stand in for the second factor", async () => {
cookie(MFA_USER, { reproofAt: NOW() });
const err = await refusal(requireRecentProof({ bearer: "elevation" }));
expect(err.extraMeta).toEqual({ methods: ["totp"] });
});
it("nor does a fresh session without the factor (a remembered device)", async () => {
cookie(MFA_USER, { createdAt: NOW() });
await refusal(requireRecentProof({ bearer: "elevation" }));
});
it("refuses a stale second-factor stamp", async () => {
cookie(MFA_USER, { mfaVerifiedAt: OLD() });
await refusal(requireRecentProof({ bearer: "elevation" }));
});
});
describe("Bearer", () => {
it("`elevation` refuses a token without one", async () => {
bearer(PLAIN_USER);
const err = await refusal(requireRecentProof({ bearer: "elevation" }));
expect(err.errorCode).toBe("auth.stepup.required");
});
it("`elevation-if-enrolled` passes a token on an account without a second factor", async () => {
bearer(PLAIN_USER);
await expect(
requireRecentProof({ bearer: "elevation-if-enrolled" }),
).resolves.toBeTruthy();
});
it("`elevation-if-enrolled` refuses a bare token on an enrolled account", async () => {
bearer(MFA_USER);
await refusal(requireRecentProof({ bearer: "elevation-if-enrolled" }));
});
it("`token` passes on the token alone", async () => {
bearer(MFA_USER);
await expect(requireRecentProof({ bearer: "token" })).resolves.toBeTruthy();
});
it("`elevation` accepts a password-proved elevation on an account without a second factor", async () => {
bearer(PLAIN_USER, `hle_${"a".repeat(64)}`);
vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue({
userId: "user-1",
apiTokenId: "tok-1",
consumedAt: null,
expiresAt: new Date(Date.now() + 60_000),
method: "password",
} as never);
const auth = await requireRecentProof({ bearer: "elevation" });
expect(typeof auth.commitElevation).toBe("function");
});
it("`elevation` refuses a password-proved elevation on an enrolled account", async () => {
bearer(MFA_USER, `hle_${"a".repeat(64)}`);
vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue({
userId: "user-1",
apiTokenId: "tok-1",
consumedAt: null,
expiresAt: new Date(Date.now() + 60_000),
method: "password",
} as never);
await refusal(requireRecentProof({ bearer: "elevation" }));
});
});
describe("adding a factor over Bearer (`freshFactorIfEnrolled`)", () => {
const passwordElevation = {
userId: "user-1",
apiTokenId: "tok-1",
consumedAt: null,
expiresAt: new Date(Date.now() + 60_000),
method: "password",
};
it("a password-proved elevation still adds a first factor", async () => {
bearer(PLAIN_USER, `hle_${"a".repeat(64)}`);
vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue(
passwordElevation as never,
);
const auth = await requireMfaManagementAuth({
freshFactorIfEnrolled: true,
});
expect(auth.transport).toBe("bearer");
});
it("a password-proved elevation does not add a factor beside an existing one", async () => {
bearer(MFA_USER, `hle_${"a".repeat(64)}`);
vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue(
passwordElevation as never,
);
await refusal(requireMfaManagementAuth({ freshFactorIfEnrolled: true }));
});
it("a second-factor elevation does", async () => {
bearer(MFA_USER, `hle_${"a".repeat(64)}`);
vi.mocked(prisma.stepUpElevation.findUnique).mockResolvedValue({
...passwordElevation,
method: "totp",
} as never);
const auth = await requireMfaManagementAuth({
freshFactorIfEnrolled: true,
});
expect(auth.transport).toBe("bearer");
});
});