fix(auth): ask for a fresh proof before the record or a credential to it leaves

Exporting the whole record, making a clinician share link, minting a token,
and the admin backup download, upload and restore, data wipe and password
reset ran on a live session alone. The plaintext full backup did, while the
encrypted one beside it asked for a second factor. Whoever held a stolen
session could take the record, or leave behind a credential that outlives
the session.

These now go through one gate, requireRecentProof (assertRecentCookieProof
for the routes that resolve their caller themselves). A browser session
passes after a sign-in or re-proof within five minutes: a second factor on
an account that has one, otherwise the password or a passkey. The refusal is
401 auth.reproof.required with meta.methods, and the web opens the re-proof
dialog, posts the proof to the new POST /api/auth/reproof and retries. A
password proof stamps the new Session.reproofAt (migration 0359), never
mfaVerifiedAt, so it cannot stand in for a second factor anywhere.

On Bearer, the full backup asks for an X-Step-Up elevation; the encrypted
export asks for a second-factor elevation on an account that has one. Share
links and connector tokens keep taking the token alone, because the shipped
app calls them without an elevation and reads a 401 there as a dead
session; the guard test names them.

Key rotation keeps its stricter second-factor gate.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 19:54:38 +02:00
1 parent a7f66b8519
commit 0fc83cb0a1
56 files changed
+2028 -235

No files matched your search

@@ -159,10 +159,13 @@ describe("DELETE /api/admin/data leaves nothing it promised to delete", () => {
).toBeGreaterThan(100);
// Created after the generic seed so the cookie carries this session's id.
// The seed gives the admin a second factor, and the wipe asks for a fresh
// proof, so the session is one that has just completed it.
const session = await prisma.session.create({
data: {
userId: admin.id,
expiresAt: new Date(Date.now() + 60_000),
mfaVerifiedAt: new Date(),
},
});
cookieJar.set("healthlog_session", session.id);
@@ -248,14 +248,36 @@ describe("B2/B3 — the medication-ingest surface is unchanged", () => {
});
describe("B4 — the native client is not broken", () => {
it("admits a ['*'] token on GET /api/export/full-backup", async () => {
it("admits a ['*'] token on POST /api/export/encrypted", async () => {
// Login, passkey login-verify and refresh rotation all mint `["*"]`, so
// this is exactly the credential the iOS app holds. If this case ever
// goes red, the native client is down.
// this is exactly the credential the iOS app holds, on the whole-record
// export the app calls. If this case ever goes red, the app's backup is
// down.
await armToken(["*"], "wildcard");
const { POST } = await import("@/app/api/export/encrypted/route");
const res = await POST(
new NextRequest("https://health.example/api/export/encrypted", {
method: "POST",
headers: {
authorization: headerJar.get("authorization")!,
"content-type": "application/json",
},
body: JSON.stringify({ passphrase: "a long enough passphrase" }),
}),
);
expect(res.status).toBe(200);
await res.arrayBuffer();
});
it("passes a ['*'] token through the scope check on GET /api/export/full-backup, to the elevation it needs", async () => {
// The plaintext full backup asks a Bearer caller for a step-up
// elevation. No shipped client calls it on Bearer. What this pins is that
// the refusal is the proof gate's 401, not the scope layer's 403.
await armToken(["*"], "wildcard");
const { GET } = await import("@/app/api/export/full-backup/route");
const res = await GET(req("/api/export/full-backup"));
expect(res.status).toBe(200);
expect(res.status).toBe(401);
expect((await res.json()).meta.errorCode).toBe("auth.stepup.required");
});
it("admits a ['*'] token on a batch ingest route", async () => {
+19 -3
View File
@@ -136,6 +136,12 @@ describe("GET /api/export/measurements", () => {
const audits = await prisma.auditLog.findMany({
where: { userId: me.id, action: "user.export.measurements" },
});
console.log(
"DBG",
JSON.stringify(
await prisma.auditLog.findMany({ where: { userId: me.id } }),
),
);
expect(audits.length).toBe(1);
});
});
@@ -258,6 +264,12 @@ describe("GET /api/export/mood", () => {
const audits = await prisma.auditLog.findMany({
where: { userId: me.id, action: "user.export.mood" },
});
console.log(
"DBG",
JSON.stringify(
await prisma.auditLog.findMany({ where: { userId: me.id } }),
),
);
expect(audits.length).toBe(1);
});
});
@@ -317,9 +329,13 @@ describe("GET /api/export/full-backup", () => {
expect(parsed.measurements.length).toBe(1);
expect(parsed.moodEntries.length).toBe(1);
const audits = await prisma.auditLog.findMany({
where: { userId: me.id, action: "user.export.full-backup" },
// The audit row is written once the last byte has gone out, which is
// after the body resolves here; wait for it rather than race it.
await vi.waitFor(async () => {
const audits = await prisma.auditLog.findMany({
where: { userId: me.id, action: "user.export.full-backup" },
});
expect(audits.length).toBe(1);
});
expect(audits.length).toBe(1);
});
});
+212
View File
@@ -0,0 +1,212 @@
/**
* The fresh-proof gate in front of whole-record exports, share links, token
* minting and the admin data actions, against real Postgres.
*
* A live browser session is not enough for these: it needs a sign-in or a
* re-proof inside five minutes (a second factor, on an account that has one).
* `POST /api/auth/reproof` is how a session re-proves in place.
*/
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-recent-proof-32-bytes-minimum-1234567890ab";
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-recent-proof";
const PASSWORD = "Correct horse battery staple!42";
const HOUR = 60 * 60 * 1000;
type RouteFn = (request: NextRequest) => Promise<Response>;
function request(method: string, path: string, body?: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method,
headers: body === undefined ? {} : { "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}
async function fullBackup() {
const { GET } = await import("@/app/api/export/full-backup/route");
return (GET as unknown as RouteFn)(request("GET", "/api/export/full-backup"));
}
async function reproof(body: unknown) {
const { POST } = await import("@/app/api/auth/reproof/route");
return (POST as unknown as RouteFn)(
request("POST", "/api/auth/reproof", body),
);
}
async function startSession(createdAgoMs: number): Promise<string> {
const now = Date.now();
const session = await getPrismaClient().session.create({
data: {
userId: USER_ID,
createdAt: new Date(now - createdAgoMs),
expiresAt: new Date(now + HOUR),
},
});
cookieJar.set("healthlog_session", session.id);
return session.id;
}
async function body(res: Response) {
return (await res.json()) as {
meta?: { errorCode?: string; methods?: string[] };
};
}
async function enrollTotp(): 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: new Date(),
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() });
}
beforeEach(async () => {
vi.clearAllMocks();
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "recent-proof-user",
email: "recent-proof@example.test",
role: "USER",
passwordHash: await hashPassword(PASSWORD),
},
});
});
describe("account without a second factor", () => {
it("exports right after signing in", async () => {
await startSession(30_000);
const res = await fullBackup();
expect(res.status).toBe(200);
await res.text();
});
it("refuses an old session and names the password as the way through", async () => {
await startSession(HOUR);
const res = await fullBackup();
expect(res.status).toBe(401);
const b = await body(res);
expect(b.meta?.errorCode).toBe("auth.reproof.required");
expect(b.meta?.methods).toEqual(["password"]);
});
it("exports after a password re-proof, which stamps only reproofAt", async () => {
const sessionId = await startSession(HOUR);
const proved = await reproof({ method: "password", password: PASSWORD });
expect(proved.status).toBe(200);
const row = await getPrismaClient().session.findUniqueOrThrow({
where: { id: sessionId },
});
expect(row.reproofAt).not.toBeNull();
expect(row.mfaVerifiedAt).toBeNull();
const res = await fullBackup();
expect(res.status).toBe(200);
await res.text();
});
it("refuses and audits a wrong password without stamping", async () => {
const sessionId = await startSession(HOUR);
const res = await reproof({ method: "password", password: "nope" });
expect(res.status).toBe(401);
expect((await body(res)).meta?.errorCode).toBe("auth.reproof.failed");
const row = await getPrismaClient().session.findUniqueOrThrow({
where: { id: sessionId },
});
expect(row.reproofAt).toBeNull();
expect(
await getPrismaClient().auditLog.count({
where: { userId: USER_ID, action: "auth.mfa.failed" },
}),
).toBe(1);
});
});
describe("account with a second factor", () => {
it("refuses the password as a proof before spending a guess", async () => {
await enrollTotp();
await startSession(HOUR);
const res = await reproof({ method: "password", password: PASSWORD });
expect(res.status).toBe(422);
const b = await body(res);
expect(b.meta?.errorCode).toBe("auth.reproof.too_weak");
expect(b.meta?.methods).toEqual(["totp"]);
expect(
await getPrismaClient().rateLimit.count({
where: { key: `auth:step-up:${USER_ID}` },
}),
).toBe(0);
});
it("a fresh sign-in alone is not enough; a TOTP re-proof is", async () => {
const secret = await enrollTotp();
const sessionId = await startSession(30_000);
const refused = await fullBackup();
expect(refused.status).toBe(401);
expect((await body(refused)).meta?.methods).toEqual(["totp"]);
const proved = await reproof({ method: "totp", code: codeFor(secret) });
expect(proved.status).toBe(200);
const row = await getPrismaClient().session.findUniqueOrThrow({
where: { id: sessionId },
});
expect(row.mfaVerifiedAt).not.toBeNull();
const res = await fullBackup();
expect(res.status).toBe(200);
await res.text();
});
});