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

@@ -12,6 +12,15 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
session: {
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
measurement: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
@@ -31,9 +31,17 @@ vi.mock("@/lib/db", () => ({
consentReceipt: { findMany: vi.fn() },
nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) },
user: { findUnique: vi.fn() },
passkey: { findMany: vi.fn() },
passkey: { findMany: vi.fn(), count: vi.fn(async () => 0) },
apiToken: { count: vi.fn() },
session: { count: vi.fn() },
session: {
count: vi.fn(),
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
appSettings: { findUnique: vi.fn() },
userAchievement: {
findMany: vi.fn(),
@@ -75,6 +75,7 @@ vi.mock("@/lib/db", () => ({
inboundDocument: { findMany: vi.fn().mockResolvedValue([]) },
session: { findUnique: vi.fn() },
webauthnMfaCredential: { count: vi.fn() },
passkey: { count: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -114,6 +115,14 @@ beforeEach(() => {
// Default: no registered security key. Tests that exercise the
// WebAuthn-only cohort override this.
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
// Default: a session that signed in a moment ago. The cases about the
// recent-proof gate set their own stamps.
vi.mocked(prisma.session.findUnique).mockResolvedValue({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
});
describe("POST /api/export/encrypted", () => {
@@ -147,6 +156,38 @@ describe("POST /api/export/encrypted", () => {
});
});
it("refuses a single-factor account whose session is not recent (401 reproof)", async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
session: { id: "sess-1" },
} as never);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
expect(res.status).toBe(401);
const body = await res.json();
expect(body.meta.errorCode).toBe("auth.reproof.required");
});
it("allows a single-factor account after a password re-proof", async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: "user-1", role: "USER", totpConfirmedAt: null },
session: { id: "sess-1" },
} as never);
vi.mocked(prisma.session.findUnique).mockResolvedValue({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: new Date(),
} as never);
const res = await POST(mkReq({ passphrase: PASSPHRASE }));
expect(res.status).toBe(200);
});
it("blocks an MFA account without a fresh second factor (401 step-up)", async () => {
vi.mocked(getSession).mockResolvedValue({
user: { id: "user-1", role: "USER", totpConfirmedAt: new Date() },
+15 -18
View File
@@ -9,10 +9,9 @@
* `application/octet-stream`.
*
* SECURITY:
* - Step-up: when the account has any second factor enrolled (TOTP OR a
* security key), this is gated by `requireFreshMfaIfEnrolled` — exporting the
* whole record is a sensitive action. Single-factor accounts fall back to a
* normal authenticated session.
* - Fresh proof: `requireRecentProof`, the gate every whole-record export
* shares. A second factor within five minutes on an account that has one;
* otherwise a recent sign-in or a password re-proof.
* - The passphrase NEVER hits a log or wide-event: it is read off the parsed
* body, passed straight into the KDF, and never `annotate()`d. The egress
* redaction denylist already scrubs `/passphrase/i` (key-name) and the
@@ -20,19 +19,16 @@
* - There is NO server-side recovery — the passphrase is not stored. A
* forgotten passphrase means the archive is unrecoverable; the UI says so.
*
* Auth: cookie session OR Bearer for single-factor accounts; cookie + fresh
* second factor for MFA accounts (Bearer can never satisfy step-up).
* Auth: cookie session with a recent proof; Bearer on the token alone for an
* account without a second factor, with a second-factor `X-Step-Up`
* elevation for one with.
* Rate-limit: shared `export:<userId>` bucket (10/h) — same bucket as the
* plaintext export so the encrypted variant cannot be used to bypass the cap.
* Audit: `user.export.encrypted` with the row counts (never the passphrase).
*/
import { prisma } from "@/lib/db";
import { z } from "zod/v4";
import {
apiHandler,
requireFreshMfaIfEnrolled,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { apiHandler, requireRecentProof } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -92,13 +88,13 @@ const encryptedExportSchema = z
.strict();
export const POST = apiHandler(async (request: NextRequest) => {
// Resolve the session (cookie or Bearer), then escalate to a fresh second
// factor when the account has one enrolled. `requireFreshMfaIfEnrolled`
// covers BOTH cohorts — a confirmed TOTP secret AND a registered WebAuthn
// security key — so a security-key-only account is gated too. A single-factor
// account cannot produce a fresh-MFA proof, so it passes straight through
// rather than being locked out of its own encrypted export.
const auth = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
// A fresh proof, like every whole-record export (`requireRecentProof`). On
// the cookie path: a second factor within five minutes on an account that
// has one, otherwise a recent sign-in or password re-proof. On Bearer the
// shipped app calls this route without an elevation, so an account without
// a second factor still passes on its token; one with a second factor
// presents a second-factor elevation (before, it could not export at all).
const auth = await requireRecentProof({ bearer: "elevation-if-enrolled" });
const user = auth.user;
annotate({ action: { name: "user.export.encrypted" } });
@@ -119,6 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error);
}
const { passphrase } = parsed.data;
await auth.commitElevation();
// Sealed as it is produced and spooled to a temporary file, then sent: the
// format puts the tag in front of the ciphertext, so the archive cannot go
+9 -3
View File
@@ -25,12 +25,16 @@
* included; the payload's `manifest` field discloses both, and the export UI
* states this too.
*
* Auth: cookie session OR Bearer token (`requireAuth`).
* Auth: a fresh proof (`requireRecentProof`). The file is the whole record in
* plain text, so a live session alone is not enough: a cookie session needs a
* recent sign-in or re-proof (a second factor on an account that has one), a
* Bearer caller an `X-Step-Up` elevation. No shipped client calls this route
* on Bearer.
* Rate-limit: shared `export:<userId>` bucket (10/h).
* Audit: `user.export.full-backup` with the row counts.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireRecentProof } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
@@ -41,13 +45,15 @@ import { streamToResponseBody } from "@/lib/export/response-stream";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const auth = await requireRecentProof({ bearer: "elevation" });
const { user } = auth;
annotate({ action: { name: "user.export.full-backup" } });
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 10 exports per hour", 429);
}
await auth.commitElevation();
// Written into the response as it is produced (`streamFullBackupJson`), not
// built and stringified first: for an account of 1.25 million measurements