Files
HealthLog/tests/integration/credential-enrollment-proof.test.ts
T
Marc-André Bombeck 7b5bc130d8 fix(auth): require a fresh proof before a second factor is added
A browser session on its own could start TOTP enrollment or register a
security key. Combined with a pending secret counting as a factor, that let
a stolen session give itself a second factor, register a passkey with it,
and keep access after the owner changed the password.

TOTP setup and security-key registration now pass on the cookie path only
when the session signed in or completed a second factor in the last five
minutes, or when the body carries a proof in the step-up shape (password,
a confirmed authenticator code, or a passkey or security-key assertion).
The settings cards ask for that proof when the server answers
auth.reproof.required and retry the same call with it. The Bearer path is
unchanged: its elevation already is the proof.

Every route that checks a password or a factor as a re-proof now uses one
verifier and one per-account budget of five attempts per fifteen minutes,
shared with the step-up mint, and records each refused proof as
auth.mfa.failed. Passkey enrollment and password change had neither the
limit (passkey enrollment) nor the audit row (both). A structural test
freezes the set of credential-verifying routes and requires a throttle and
an audit call in each.
2026-09-26 15:55:57 +02:00

266 lines
8.7 KiB
TypeScript

/**
* Adding a factor to an account needs a fresh proof, not just a live session.
*
* The chain this closes: a stolen browser session starts TOTP enrollment, reads
* a code from the secret it was just handed, and presents that code as "an
* existing second factor" — to mint a step-up, to register its own passkey, to
* pass the fresh-factor gates. Two rules break it, and both are pinned here
* against real Postgres:
*
* 1. a code from a secret that was never confirmed is not a factor anywhere;
* 2. the cookie arm of TOTP and security-key enrollment wants a sign-in or a
* second factor from the last five minutes, or a proof in the body.
*/
import { NextRequest } from "next/server";
import * as OTPAuth from "otpauth";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-enrollment-proof-32-bytes-minimum-1234567890";
const { hashToken } = await import("@/lib/auth/hmac");
const { hashPassword } = await import("@/lib/auth/password");
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const USER_ID = "user-enroll-proof";
const PASSWORD = "Correct horse battery staple!42";
const HOUR = 60 * 60 * 1000;
type RouteFn = (request: NextRequest) => Promise<Response>;
function post(path: string, body?: unknown): NextRequest {
const headers: Record<string, string> = {};
for (const name of ["authorization", "x-step-up"]) {
const value = headerJar.get(name);
if (value) headers[name] = value;
}
if (body !== undefined) headers["content-type"] = "application/json";
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}
async function totpSetup(body?: unknown) {
const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route");
return (POST as unknown as RouteFn)(
post("/api/auth/me/mfa/totp/setup", body),
);
}
async function securityKeyOptions(body?: unknown) {
const { POST } =
await import("@/app/api/auth/me/mfa/webauthn/register/options/route");
return (POST as unknown as RouteFn)(
post("/api/auth/me/mfa/webauthn/register/options", body),
);
}
async function stepUpMint(body: unknown) {
const { POST } = await import("@/app/api/auth/step-up/route");
return (POST as unknown as RouteFn)(post("/api/auth/step-up", body));
}
async function startSession(opts: {
createdAgoMs: number;
mfaVerifiedAgoMs?: number;
}): Promise<string> {
const now = Date.now();
const session = await getPrismaClient().session.create({
data: {
userId: USER_ID,
createdAt: new Date(now - opts.createdAgoMs),
expiresAt: new Date(now + HOUR),
mfaVerifiedAt:
opts.mfaVerifiedAgoMs === undefined
? null
: new Date(now - opts.mfaVerifiedAgoMs),
},
});
cookieJar.set("healthlog_session", session.id);
return session.id;
}
async function seedPendingTotp(): Promise<string> {
const { generateTotpSecret } = await import("@/lib/auth/mfa/totp");
const { encrypt } = await import("@/lib/crypto");
const secret = generateTotpSecret();
await getPrismaClient().user.update({
where: { id: USER_ID },
data: {
totpSecretEncrypted: encrypt(secret),
totpConfirmedAt: null,
totpLastStep: null,
},
});
return secret;
}
function codeFor(secret: string): string {
return new OTPAuth.TOTP({
issuer: "HealthLog",
label: "HealthLog",
algorithm: "SHA1",
digits: 6,
period: 30,
secret: OTPAuth.Secret.fromBase32(secret),
}).generate({ timestamp: Date.now() });
}
async function errorCode(res: Response): Promise<string | undefined> {
const body = (await res.json()) as { meta?: { errorCode?: string } };
return body.meta?.errorCode;
}
async function storedSecret(): Promise<string | null> {
const user = await getPrismaClient().user.findUniqueOrThrow({
where: { id: USER_ID },
select: { totpSecretEncrypted: true },
});
return user.totpSecretEncrypted;
}
beforeEach(async () => {
vi.clearAllMocks();
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
process.env.APP_URL = "http://localhost:3000";
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "enroll-user",
email: "enroll@example.test",
role: "USER",
passwordHash: await hashPassword(PASSWORD),
},
});
});
describe("TOTP enrollment on the cookie arm", () => {
it("refuses a session that is neither fresh nor carrying a proof", async () => {
await startSession({ createdAgoMs: HOUR });
const res = await totpSetup();
expect(res.status).toBe(401);
expect(await errorCode(res)).toBe("auth.reproof.required");
expect(await storedSecret()).toBeNull();
});
it("enrolls with the account password in the body", async () => {
await startSession({ createdAgoMs: HOUR });
const res = await totpSetup({ method: "password", password: PASSWORD });
expect(res.status).toBe(200);
expect(await storedSecret()).not.toBeNull();
});
it("enrolls without a body right after signing in", async () => {
await startSession({ createdAgoMs: 30_000 });
const res = await totpSetup();
expect(res.status).toBe(200);
});
it("enrolls without a body after a recent second factor", async () => {
await startSession({ createdAgoMs: HOUR, mfaVerifiedAgoMs: 60_000 });
const res = await totpSetup();
expect(res.status).toBe(200);
});
it("refuses and audits a wrong password", async () => {
await startSession({ createdAgoMs: HOUR });
const res = await totpSetup({ method: "password", password: "nope" });
expect(res.status).toBe(401);
expect(await errorCode(res)).toBe("auth.reproof.failed");
expect(await storedSecret()).toBeNull();
expect(
await getPrismaClient().auditLog.count({
where: { userId: USER_ID, action: "auth.mfa.failed" },
}),
).toBe(1);
});
it("does not take a code from its own pending secret as the proof", async () => {
const secret = await seedPendingTotp();
await startSession({ createdAgoMs: HOUR });
const res = await totpSetup({ method: "totp", code: codeFor(secret) });
expect(res.status).toBe(401);
expect(await errorCode(res)).toBe("auth.reproof.failed");
});
it("cannot be guessed at past the shared re-proof ceiling", async () => {
await startSession({ createdAgoMs: HOUR });
const statuses: number[] = [];
for (let i = 0; i < 6; i++) {
statuses.push(
(await totpSetup({ method: "password", password: `guess-${i}` }))
.status,
);
}
expect(statuses).toEqual([401, 401, 401, 401, 401, 429]);
});
});
describe("security-key enrollment on the cookie arm", () => {
it("refuses a stale session without a proof and creates no challenge", async () => {
await startSession({ createdAgoMs: HOUR });
const res = await securityKeyOptions();
expect(res.status).toBe(401);
expect(await errorCode(res)).toBe("auth.reproof.required");
expect(await getPrismaClient().authChallenge.count()).toBe(0);
});
it("issues options once the password is re-proved", async () => {
await startSession({ createdAgoMs: HOUR });
const res = await securityKeyOptions({
method: "password",
password: PASSWORD,
});
expect(res.status).toBe(200);
expect(await getPrismaClient().authChallenge.count()).toBe(1);
});
});
describe("a pending TOTP secret on the Bearer arm", () => {
it("cannot mint a step-up elevation", async () => {
const secret = await seedPendingTotp();
const raw = `hlk_enrollproof${"0".repeat(64 - "enrollproof".length)}`;
await getPrismaClient().apiToken.create({
data: {
userId: USER_ID,
name: "phone",
tokenHash: hashToken(raw),
permissions: ["*"],
},
});
headerJar.set("authorization", `Bearer ${raw}`);
const res = await stepUpMint({ method: "totp", code: codeFor(secret) });
expect(res.status).toBe(401);
expect(await getPrismaClient().stepUpElevation.count()).toBe(0);
});
});