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

@@ -18,7 +18,12 @@ import { Readable } from "node:stream";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import {
apiHandler,
HttpError,
requireAdmin,
assertRecentCookieProof,
} from "@/lib/api-handler";
import { apiError, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -42,7 +47,10 @@ export const GET = apiHandler(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user: admin } = await requireAdmin();
const { user: admin, session } = await requireAdmin();
// A fresh proof on top of the admin session: this reaches every account's
// data, so a stolen admin session alone must not be enough.
await assertRecentCookieProof(admin, session.id);
const { id } = await params;
annotate({ action: { name: "admin.backups.download" }, meta: { id } });
@@ -20,7 +20,12 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import {
apiHandler,
HttpError,
requireAdmin,
assertRecentCookieProof,
} from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -45,7 +50,10 @@ const handler = apiHandler(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) => {
const { user: admin } = await requireAdmin();
const { user: admin, session } = await requireAdmin();
// A fresh proof on top of the admin session: this reaches every account's
// data, so a stolen admin session alone must not be enough.
await assertRecentCookieProof(admin, session.id);
const { id } = await params;
annotate({ action: { name: "admin.backups.restore" }, meta: { id } });
+10 -2
View File
@@ -35,7 +35,12 @@ import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
import { NextRequest } from "next/server";
import { ZodError } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import {
apiHandler,
HttpError,
requireAdmin,
assertRecentCookieProof,
} from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan";
@@ -143,7 +148,10 @@ async function* decodedBytes(
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user: admin } = await requireAdmin();
const { user: admin, session } = await requireAdmin();
// A fresh proof on top of the admin session: this reaches every account's
// data, so a stolen admin session alone must not be enough.
await assertRecentCookieProof(admin, session.id);
annotate({ action: { name: "admin.backups.upload" } });
const ipAddress = getClientIp(request);
+9 -2
View File
@@ -1,5 +1,9 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiHandler,
requireAdmin,
assertRecentCookieProof,
} from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -47,7 +51,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
);
}
const { user } = await requireAdmin();
const { user, session } = await requireAdmin();
// A fresh proof on top of the admin session: this reaches every account's
// data, so a stolen admin session alone must not be enough.
await assertRecentCookieProof(user, session.id);
annotate({ action: { name: "admin.data.delete" } });
// v1.18.1 — the documented convention buckets authenticated admin
@@ -1,5 +1,9 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiHandler,
requireAdmin,
assertRecentCookieProof,
} from "@/lib/api-handler";
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -31,7 +35,10 @@ export const POST = apiHandler(
);
}
const { user } = await requireAdmin();
const { user, session } = await requireAdmin();
// A fresh proof on top of the admin session: this reaches every account's
// data, so a stolen admin session alone must not be enough.
await assertRecentCookieProof(user, session.id);
const { id } = await params;
annotate({
+139
View File
@@ -0,0 +1,139 @@
/**
* POST /api/auth/reproof
*
* A browser session re-proves a credential the account holds, so that for the
* next five minutes it may export the whole record, make a share link, mint a
* token, or run an admin backup, restore, wipe or reset. Those routes answer
* 401 `auth.reproof.required` with `meta.methods` when the session carries no
* recent proof; the web opens the re-proof dialog, posts the proof here, and
* retries.
*
* What a proof stamps on the session row:
* - `totp`, `webauthn` or `passkey`: `mfaVerifiedAt` and `reproofAt`. The
* same stamp a completed second factor or passkey sign-in writes, from the
* same factor, so it also clears the fresh-factor routes, exactly as
* signing in again would.
* - `password`: `reproofAt` only. Accepted only on an account without a
* second factor; an account with one is told which proofs it can give
* instead (422 `auth.reproof.too_weak`), because a password has never
* stood in for the second factor.
*
* Cookie-only. The Bearer transport has its own proof flow (`POST
* /api/auth/step-up`, token-bound single-use elevations), and a stamp on a
* session row means nothing to a token.
*
* GET asks the same question the gated routes ask, without acting: 200 when
* the session's proof is recent, otherwise the same 401 `auth.reproof.required`
* with `meta.methods`. The web calls it before a large upload, so the file is
* not sent twice around a re-proof.
*
* Proofs draw on the account's shared re-proof budget (five per fifteen
* minutes, the same bucket as the step-up mint and the enrollment routes) and
* every refusal is audited. Failures are indistinguishable on the wire.
*/
import { NextRequest } from "next/server";
import {
apiHandler,
assertRecentCookieProof,
recentProofMethods,
requireCookieAuth,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
REPROOF_FAILED_CODE,
recordReproofFailure,
throttleReproof,
verifyExistingFactorProof,
} from "@/lib/auth/existing-factor-proof";
import { stepUpMintSchema } from "@/lib/validations/step-up";
export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) => {
const { user, session } = await requireCookieAuth();
const ip = getClientIp(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = stepUpMintSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
"Invalid request",
sanitiseZodIssues(parsed.error.issues),
422,
);
}
const proof = parsed.data;
// Refuse a proof that could not satisfy the gate BEFORE spending a guess on
// it: a password on an account with a second factor would verify and still
// leave every gated action refused.
const methods = await recentProofMethods(user);
if (!methods.includes(proof.method)) {
annotate({
action: { name: "auth.reproof.method_refused" },
meta: { method: proof.method },
});
return apiError("Use one of the listed ways to confirm it is you", 422, {
errorCode: "auth.reproof.too_weak",
methods,
});
}
const limited = await throttleReproof(user.id, ip, "reproof");
if (limited) return limited;
const outcome = await verifyExistingFactorProof(user, proof);
if (!outcome.ok) {
await recordReproofFailure(
user.id,
ip,
"reproof",
proof.method,
outcome.reason,
);
return apiError("Verification failed", 401, {
errorCode: REPROOF_FAILED_CODE,
});
}
const now = new Date();
await prisma.session.update({
where: { id: session.id },
data:
proof.method === "password"
? { reproofAt: now }
: { reproofAt: now, mfaVerifiedAt: now },
});
await auditLog("auth.reproof.succeeded", {
userId: user.id,
ipAddress: ip,
details: { method: proof.method },
});
annotate({
action: { name: "auth.reproof.succeeded" },
meta: { method: proof.method },
});
return apiSuccess({ method: proof.method, verifiedAt: now.toISOString() });
});
export const GET = apiHandler(async () => {
const { user, session } = await requireCookieAuth();
await assertRecentCookieProof(user, session.id);
return apiSuccess({ recent: true });
});
@@ -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
@@ -10,6 +10,15 @@ vi.mock("@/lib/db", () => ({
prisma: {
apiToken: { findMany: vi.fn() },
auditLog: { create: vi.fn() },
session: {
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -29,6 +38,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { issueApiToken } from "@/lib/auth/issue-token";
@@ -57,6 +67,20 @@ beforeEach(() => {
} as never);
});
describe("POST /api/mcp/tokens — fresh proof", () => {
it("refuses a browser session with no recent sign-in or re-proof", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
const res = await POST(postReq({ name: "laptop" }));
expect(res.status).toBe(401);
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
expect(issueApiToken).not.toHaveBeenCalled();
});
});
describe("POST /api/mcp/tokens — scope minting", () => {
it("defaults to read-only [health:read]", async () => {
const res = await POST(postReq({ name: "laptop" }));
+6 -2
View File
@@ -19,7 +19,7 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecentProof } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -71,7 +71,11 @@ export const GET = apiHandler(async () => {
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// A connector token outlives the session that mints it, so the cookie path
// needs a fresh proof. The shipped app mints these on its token without an
// elevation and reads a 401 here as a dead session, so the Bearer arm stays
// on the token until it sends one (see the iOS note).
const { user } = await requireRecentProof({ bearer: "token" });
annotate({ action: { name: "mcp.tokens.create" } });
if (!(await isApiGloballyEnabled())) {
@@ -19,6 +19,15 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn(),
},
auditLog: { create: vi.fn() },
session: {
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
},
}));
@@ -101,6 +110,22 @@ beforeEach(() => {
} as never);
});
describe("POST /api/share-links — fresh proof", () => {
it("refuses a browser session with no recent sign-in or re-proof", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
const res = await POST(postReq(validBody()));
expect(res.status).toBe(401);
const body = await res.json();
expect(body.meta.errorCode).toBe("auth.reproof.required");
expect(body.meta.methods).toEqual([]);
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
});
});
describe("POST /api/share-links — create", () => {
it("returns the raw token once and persists ONLY its hash", async () => {
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
+7 -2
View File
@@ -15,7 +15,7 @@
import { randomBytes } from "node:crypto";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecentProof } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
@@ -136,7 +136,12 @@ function toSummary(row: {
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
// A share link is a standing credential to the record that works without
// signing in, so making one needs a fresh proof on the cookie path, not only
// a live session. The shipped app creates links on its token without an
// elevation, and reads any 401 here as a dead session, so the Bearer arm
// stays on the token until the app sends one (see the iOS note).
const { user } = await requireRecentProof({ bearer: "token" });
annotate({ action: { name: "share-link.create" } });
const rl = await checkRateLimit(`share-link:${user.id}`, 20, 60 * 60 * 1000);
@@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireCookieAuth: vi.fn(),
assertRecentCookieProof: vi.fn(),
}));
vi.mock("@/lib/auth/issue-token", () => ({
@@ -42,7 +43,7 @@ vi.mock("@/lib/db", () => ({
}));
import { POST } from "../route";
import { requireCookieAuth } from "@/lib/api-handler";
import { assertRecentCookieProof, requireCookieAuth } from "@/lib/api-handler";
import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -63,7 +64,11 @@ function req(body: unknown): Request {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never);
vi.mocked(requireCookieAuth).mockResolvedValue({
user: USER,
session: { id: "sess-1" },
} as never);
vi.mocked(assertRecentCookieProof).mockResolvedValue(undefined);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
@@ -77,6 +82,26 @@ beforeEach(() => {
} as never);
});
describe("the fresh proof in front of the mint", () => {
it("mints nothing when the session carries no recent proof", async () => {
vi.mocked(assertRecentCookieProof).mockRejectedValue(
new Error("auth.reproof.required"),
);
await expect(POST(req({ name: "Scale" }) as never)).rejects.toThrow(
"auth.reproof.required",
);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("asks about the caller's own session", async () => {
await POST(req({ name: "Scale" }) as never);
expect(assertRecentCookieProof).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.any(String) }),
"sess-1",
);
});
});
describe("what it mints", () => {
it("names exactly the upload scope, and no wildcard", async () => {
const res = await POST(req({ name: "Paperless workflow" }) as never);
+10 -2
View File
@@ -23,7 +23,11 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import {
apiHandler,
assertRecentCookieProof,
requireCookieAuth,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -55,7 +59,11 @@ const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
const MAX_LIVE_TOKENS = 10;
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireCookieAuth();
const { user, session } = await requireCookieAuth();
// And a fresh proof on top of the session: the token lives far longer than
// a stolen session would, so minting one needs the person, not just the
// browser.
await assertRecentCookieProof(user, session.id);
annotate({ action: { name: "tokens.documents.create" } });
if (!(await isApiGloballyEnabled())) {
@@ -14,6 +14,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireCookieAuth: vi.fn(),
assertRecentCookieProof: vi.fn(),
}));
vi.mock("@/lib/auth/issue-token", () => ({
@@ -42,7 +43,7 @@ vi.mock("@/lib/db", () => ({
}));
import { POST } from "../route";
import { requireCookieAuth } from "@/lib/api-handler";
import { assertRecentCookieProof, requireCookieAuth } from "@/lib/api-handler";
import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -63,7 +64,11 @@ function req(body: unknown): Request {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireCookieAuth).mockResolvedValue({ user: USER } as never);
vi.mocked(requireCookieAuth).mockResolvedValue({
user: USER,
session: { id: "sess-1" },
} as never);
vi.mocked(assertRecentCookieProof).mockResolvedValue(undefined);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
@@ -77,6 +82,26 @@ beforeEach(() => {
} as never);
});
describe("the fresh proof in front of the mint", () => {
it("mints nothing when the session carries no recent proof", async () => {
vi.mocked(assertRecentCookieProof).mockRejectedValue(
new Error("auth.reproof.required"),
);
await expect(POST(req({ name: "Scale" }) as never)).rejects.toThrow(
"auth.reproof.required",
);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("asks about the caller's own session", async () => {
await POST(req({ name: "Scale" }) as never);
expect(assertRecentCookieProof).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.any(String) }),
"sess-1",
);
});
});
describe("what it mints", () => {
it("names exactly the ingest scope, and no wildcard", async () => {
const res = await POST(req({ name: "Home Assistant" }) as never);
+10 -2
View File
@@ -30,7 +30,11 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
import {
apiHandler,
assertRecentCookieProof,
requireCookieAuth,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -94,7 +98,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
// front of, and it is the surface this endpoint is reached from anyway. The
// same argument the passkey-registration and trusted-device routes make, and
// `requireCookieAuth` is the helper they share.
const { user } = await requireCookieAuth();
const { user, session } = await requireCookieAuth();
// And a fresh proof on top of the session: the token lives far longer than
// a stolen session would, so minting one needs the person, not just the
// browser.
await assertRecentCookieProof(user, session.id);
annotate({ action: { name: "tokens.measurements.create" } });
if (!(await isApiGloballyEnabled())) {