Merge pull request #541 from MBombeck/release/v1.30.17

release/v1.30.17 — API tokens now enforce their scope
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-19 01:25:37 +02:00
commit 40ad2dfc96
24 files changed
+864 -451

No files matched your search

+11
View File
@@ -2,6 +2,17 @@
## [Unreleased]
## [1.30.17] — 2026-07-19
API tokens now enforce their scope.
- **A token minted for medication intake is refused outside the ingest endpoint.** Previously any valid token reached every authenticated route regardless of the scope it carried, so a token handed to a third-party automation could read the full-account backup export, lab results, measurements and the assistant. A route that accepts a narrow-scope token now has to name that scope; a route that names none accepts browser sessions and the app's own full-access tokens only. Tokens issued to the web session, the native client and the MCP connector are unaffected.
- **Revoke and re-issue any token you handed to a third party.** A token that could reach the backup export should be treated as having had that reach for its whole lifetime, not only from the moment it was noticed.
- **The generic "create API token" button is gone from settings.** The token it minted never worked for the medication intake it advertised — the ingest endpoint also requires a per-medication grant that this button never issued — while reaching everything else. Tokens are issued per medication from the medication's own API-endpoint toggle, which grants exactly that one medication. Existing tokens stay listed and revocable.
- **MCP connector tokens are now bound to the MCP endpoint alone.** They previously also passed on read-only REST requests. No connector flow used that.
No breaking changes to the web session, the native client, or the MCP connector.
## [1.30.16] — 2026-07-19
Stops an ongoing loss of resting heart-rate history.
+2 -1
View File
@@ -88,7 +88,8 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
- **RSC by default.** Server components are the default; add `"use client"` only when a hook, state, or browser API actually needs it.
- **Every API route wraps in `apiHandler`.** Verified — zero exceptions in the current tree. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`).
- **`userId` is always narrowed from session or Bearer.** No route accepts `userId` as a body field — it comes from `requireAuth()` and feeds the Prisma `where`. The batch endpoints (`/api/measurements/batch`, `/api/mood-entries/bulk`, `/api/medications/intake/bulk`, `/api/workouts/batch`) don't even declare a `userId` field in their Zod schemas.
- **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` hardcodes the narrow scope so the user-facing endpoint can never mint a wildcard token.
- **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` builds `permissions` from a hardcoded literal, never from request input.
- **Bearer scope is fail-closed.** `requireAuth()` with no argument admits a cookie session or a wildcard (`["*"]`) token only — a narrow-scope token is refused with 403 and an `undeclared_scope` audit row. A route that means to accept a narrow token names the scope: `requireAuth(FHIR_READ_SCOPE)`. Adding a route therefore cannot silently widen an existing token's reach, because the default arm denies. Only the login / passkey / refresh paths mint `["*"]`; every user-facing mint is narrow, and narrow now means narrow. Two surfaces resolve a Bearer token outside `requireAuth` — `/api/ingest/medication` and `/mcp` — and that set is frozen by a structural test (`src/__tests__/bearer-scope-enforcement-guard.test.ts`).
- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary in `requireAdmin()` (`src/lib/api-handler.ts`), not a runtime check that future code can soften.
- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see the whitelist-splice block in `src/lib/rollups/measurement-rollups.ts`, around the `$queryRawUnsafe` calls). Inline comments document the whitelist at the splice point.
- **TanStack Query keys live in the centralised factory.** `src/lib/query-keys/` (16 modules, `index.ts` barrel) is the only legal source of `queryKey` / `mutationKey` arrays. The in-repo ESLint rule `healthlog/queryKey-factory` (set to `error`) flags any bare array. Same-key + different `queryFn` shape silently poisons the cache; the factory prevents it. Every read unwraps `(await res.json()).data` from the envelope.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.30.16
version: 1.30.17
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+4 -4
View File
@@ -65,10 +65,10 @@ Still in **Settings → MCP → Tokens**:
3. **Copy the `hlk_…` value now** — it is shown exactly once and cannot
be retrieved again. Tokens default to a 90-day lifetime.
The token never carries the `*` wildcard and never the
`medication:ingest` scope the generic `/api/tokens` mints — it is the
least-privilege MCP scope and nothing more. The same list shows
last-used time and lets you revoke any token.
The token never carries the `*` wildcard and never a medication-ingest
scope — it is the least-privilege MCP scope and nothing more, and it is
bound to the MCP surface: it is refused on every REST route. The same
list shows last-used time and lets you revoke any token.
Cloud connectors that use OAuth (next section) mint the same
`health:read` scope automatically, so for Claude.ai / ChatGPT you do not
+2 -1
View File
@@ -5177,7 +5177,8 @@
"resumeError": "Wieder verbinden fehlgeschlagen"
},
"apiTokens": "API-Tokens",
"apiTokensDescription": "Erstelle API-Tokens für die externe Medikamenten-Einnahme (z.B. Shortcuts, Automationen).",
"apiTokensDescription": "API-Tokens für die externe Medikamenten-Einnahme prüfen und widerrufen. Tokens werden pro Medikament über dessen API-Endpoint-Schalter ausgestellt.",
"tokenMintMovedDescription": "Tokens werden pro Medikament ausgestellt. Öffne ein Medikament und aktiviere dessen API-Endpoint, um ein Token zu erhalten, das nur für dieses Medikament gilt.",
"tokenNamePlaceholder": "Token-Name (z.B. iPhone Shortcut)",
"tokenCreated": "Token erstellt — kopiere ihn jetzt! Er wird nicht erneut angezeigt.",
"tokenCopy": "Token kopieren",
+2 -1
View File
@@ -5177,7 +5177,8 @@
"resumeError": "Reconnect failed"
},
"apiTokens": "API Tokens",
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
"apiTokensDescription": "Review and revoke API tokens for external medication intake. Tokens are issued per medication from the medication's API endpoint toggle.",
"tokenMintMovedDescription": "Tokens are issued per medication. Open a medication and enable its API endpoint to get a token scoped to that medication alone.",
"tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)",
"tokenCreated": "Token created — copy it now! It won't be shown again.",
"tokenCopy": "Copy token",
+2 -1
View File
@@ -5177,7 +5177,8 @@
"resumeError": "Reconexión fallida"
},
"apiTokens": "Tokens de API",
"apiTokensDescription": "Crea tokens de API para registrar tomas de medicación externas (p. ej., Atajos, automatizaciones).",
"apiTokensDescription": "Revisa y revoca tokens de API para el registro externo de tomas de medicación. Los tokens se emiten por medicamento desde su interruptor de endpoint de API.",
"tokenMintMovedDescription": "Los tokens se emiten por medicamento. Abre un medicamento y activa su endpoint de API para obtener un token limitado únicamente a ese medicamento.",
"tokenNamePlaceholder": "Nombre del token (p. ej., Atajo de iPhone)",
"tokenCreated": "Token creado — ¡cópialo ahora! No se mostrará de nuevo.",
"tokenCopy": "Copiar token",
+2 -1
View File
@@ -5177,7 +5177,8 @@
"resumeError": "Échec de la reconnexion"
},
"apiTokens": "Jetons API",
"apiTokensDescription": "Créez des jetons API pour enregistrer des prises de médicaments externes (par exemple Raccourcis, automatisations).",
"apiTokensDescription": "Consultez et révoquez les jetons API pour l’enregistrement externe des prises de médicaments. Les jetons sont émis par médicament depuis son interrupteur de point de terminaison API.",
"tokenMintMovedDescription": "Les jetons sont émis par médicament. Ouvrez un médicament et activez son point de terminaison API pour obtenir un jeton limité à ce seul médicament.",
"tokenNamePlaceholder": "Nom du jeton (par exemple Raccourci iPhone)",
"tokenCreated": "Jeton créé — copiez-le maintenant ! Il ne sera plus affiché.",
"tokenCopy": "Copier le jeton",
+2 -1
View File
@@ -5177,7 +5177,8 @@
"resumeError": "Riconnessione fallita"
},
"apiTokens": "Token API",
"apiTokensDescription": "Crea token API per registrare assunzioni di farmaci esterne (ad es. Comandi rapidi, automazioni).",
"apiTokensDescription": "Rivedi e revoca i token API per la registrazione esterna delle assunzioni di farmaci. I token vengono emessi per singolo farmaco dal relativo interruttore dell’endpoint API.",
"tokenMintMovedDescription": "I token vengono emessi per singolo farmaco. Apri un farmaco e attiva il suo endpoint API per ottenere un token valido solo per quel farmaco.",
"tokenNamePlaceholder": "Nome del token (ad es. Comando rapido iPhone)",
"tokenCreated": "Token creato — copialo ora! Non verrà mostrato di nuovo.",
"tokenCopy": "Copia token",
+2 -1
View File
@@ -5177,7 +5177,8 @@
"resumeError": "Ponowne połączenie nieudane"
},
"apiTokens": "Tokeny API",
"apiTokensDescription": "Twórz tokeny API do zewnętrznego rejestrowania przyjęć leków (np. Skróty, automatyzacje).",
"apiTokensDescription": "Przeglądaj i unieważniaj tokeny API do zewnętrznego rejestrowania przyjęć leków. Tokeny są wydawane dla pojedynczego leku z jego przełącznika punktu końcowego API.",
"tokenMintMovedDescription": "Tokeny są wydawane dla pojedynczego leku. Otwórz lek i włącz jego punkt końcowy API, aby otrzymać token obejmujący wyłącznie ten lek.",
"tokenNamePlaceholder": "Nazwa tokena (np. Skrót iPhone)",
"tokenCreated": "Token utworzony — skopiuj go teraz! Nie zostanie pokazany ponownie.",
"tokenCopy": "Kopiuj token",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.30.16",
"version": "1.30.17",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.30.16";
/* @sw-version-fallback */ "v1.30.17";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -0,0 +1,195 @@
/**
* Structural guards on the Bearer-scope enforcement boundary.
*
* The correctness argument for the fail-closed default is structural, not
* empirical: there is exactly ONE place a raw Bearer token becomes a user, and
* exactly ONE authorisation arm inside it. Seven behavioural tests cannot cover
* 300-odd routes; what they can do is rest on that invariant. These guards are
* what keep the invariant true.
*
* They are tripwires, not proofs. They cannot show an allowlist is correct —
* only that it has not changed without someone editing this file. A reviewer
* who waves through a bad addition defeats all four, and no test substitutes
* for that review.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join, sep } from "node:path";
import { globSync } from "node:fs";
const SRC = join(process.cwd(), "src");
/**
* Every non-test `.ts` / `.tsx` under `src/`, excluding the generated Prisma
* client (9 MB; never read it) and test files themselves.
*/
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.filter(
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
function filesMatching(re: RegExp): string[] {
return sourceFiles().filter((rel) => re.test(read(rel)));
}
describe("T1 — the Bearer resolution set is frozen", () => {
/**
* Files allowed to turn a raw `Authorization: Bearer` value into a user.
* This is the set that can circumvent the enforcement point, so it is the
* only set that matters for the fail-closed guarantee.
*
* Two of these authenticate a request; the rest are the resolver itself and
* the two edges that wrap it.
*/
const RESOLUTION_ALLOWLIST = [
// The resolver. Owns the one authorisation arm.
"lib/auth/bearer.ts",
// The REST edge — maps `requireAuth`'s optional scope onto the union.
"lib/api-handler.ts",
// The MCP edge — the one deliberate `any-valid-token` posture.
"lib/mcp/auth.ts",
// The `/mcp` transport, via `resolveMcpAuthContext`.
"app/mcp/route.ts",
// Hand-rolled Bearer authentication: the external medication-ingest
// surface, which gates on BOTH `medication:ingest` and the per-medication
// `medication:<id>:ingest` grant and never touches `requireAuth`.
"app/api/ingest/medication/route.ts",
].sort();
it("no file outside the allowlist resolves a Bearer token to a user", () => {
// `resolveBearerToken` is the shared primitive; a `tokenHash` lookup is the
// hand-rolled equivalent. Either one turns a bearer credential into an
// identity, so both have to stay inside the allowlist.
const resolvers = filesMatching(
/resolveBearerToken|apiToken\.findUnique\([\s\S]*?where:\s*\{\s*tokenHash/,
);
expect(resolvers).toEqual(RESOLUTION_ALLOWLIST);
});
it("the resolver exposes exactly one authorisation arm", () => {
const src = read("lib/auth/bearer.ts");
// One wildcard escape hatch, guarding one deny block. If a second
// `permissions.includes("*")` short-circuit appears, the arm has forked.
const wildcardChecks = src.match(/permissions\.includes\("\*"\)/g) ?? [];
expect(wildcardChecks).toHaveLength(1);
});
});
describe("T2 — `any-valid-token` is the single deliberate fail-open posture", () => {
it("appears at exactly one call site, and that call site is the MCP edge", () => {
// `bearer.ts` necessarily names the variant to declare the union; what
// must stay unique is a caller PASSING it.
const optedOut = filesMatching(/kind:\s*"any-valid-token"/).filter(
(rel) => rel !== "lib/auth/bearer.ts",
);
expect(optedOut).toEqual(["lib/mcp/auth.ts"]);
});
it("no route file opts out of the fail-closed default", () => {
const routes = sourceFiles().filter((p) => p.endsWith("/route.ts"));
const optedOut = routes.filter((rel) =>
/kind:\s*"any-valid-token"/.test(read(rel)),
);
expect(optedOut).toEqual([]);
});
});
describe("T3 — the mint sites are frozen", () => {
/**
* Every place an `ApiToken` row is created, with the scope set it may mint.
* A new mint site, or a new scope on an existing one, has to be named here —
* which is the point: a user-facing mint that hands out a broad scope is the
* one failure mode the enforcement change cannot catch by itself.
*/
const MINT_SITES: Record<string, string> = {
// Cookie-equivalent. The ONLY `["*"]` mints, all behind a completed login.
"lib/auth/issue-token.ts": 'opts.permissions ?? ["*"]',
"lib/auth/login-response.ts": '["*"]',
"lib/auth/refresh-token.ts": '["*"]',
"app/api/auth/passkey/login-verify/route.ts": '["*"]',
// The working medication-ingest pair — family marker plus the per-
// medication grant that `/api/ingest/medication` actually gates on.
"app/api/medications/[id]/api-endpoint/route.ts":
'["medication:ingest", scope]',
// MCP, audience-bound to `/mcp`. `health:write` requires explicit consent.
"app/api/mcp/tokens/route.ts": "SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE",
"app/api/mcp/oauth/token/route.ts":
"SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE",
};
it("only the known files create ApiToken rows", () => {
const minters = filesMatching(/apiToken\.create\(|issueApiToken\(/).filter(
// `issue-token.ts` defines the helper; the others call it.
(rel) => rel !== "lib/auth/issue-token.ts" || true,
);
expect(minters).toEqual(Object.keys(MINT_SITES).sort());
});
it("no user-facing mint hands out a wildcard scope", () => {
// The four `["*"]` mints are all reached only by a completed
// password / passkey / refresh exchange. Every other mint is narrow.
const userFacing = [
"app/api/medications/[id]/api-endpoint/route.ts",
"app/api/mcp/tokens/route.ts",
"app/api/mcp/oauth/token/route.ts",
];
for (const rel of userFacing) {
expect(read(rel)).not.toMatch(/permissions:\s*\[\s*"\*"/);
}
});
it("the retired generic token mint is gone", () => {
// `POST /api/tokens` minted `["medication:ingest"]` — a token that could
// not perform its advertised job (it lacked the per-medication grant) and
// could reach every other authenticated route. List and revoke stay.
const tokensRoute = read("app/api/tokens/route.ts");
expect(tokensRoute).not.toMatch(/export const POST/);
expect(tokensRoute).toMatch(/export const GET/);
});
});
describe("T4 — declared scopes are exported constants, never string literals", () => {
it("every requireAuth argument is an identifier", () => {
const offenders: string[] = [];
for (const rel of sourceFiles()) {
const src = read(rel);
// Call sites only — skip the declaration in `api-handler.ts`, whose
// parameter list is not an argument.
for (const m of src.matchAll(
/(?<!function\s)requireAuth\(\s*([^)\s][^)]*)\)/g,
)) {
const arg = m[1].trim();
if (!/^[A-Za-z_$][\w$]*$/.test(arg)) {
offenders.push(`${rel}: requireAuth(${arg})`);
}
}
}
expect(offenders).toEqual([]);
});
it("the declared scope vocabulary is closed", () => {
// Scopes a route may name. Adding one means adding a route that accepts a
// narrow token, which is a widening — it belongs in a reviewed diff.
const DECLARED_SCOPES = ["FHIR_READ_SCOPE"];
const used = new Set<string>();
for (const rel of sourceFiles()) {
for (const m of read(rel).matchAll(
/(?<!function\s)requireAuth\(\s*([A-Za-z_$][\w$]*)\s*\)/g,
)) {
used.add(m[1]);
}
}
expect([...used].sort()).toEqual(DECLARED_SCOPES);
});
});
-120
View File
@@ -1,120 +0,0 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/tokens.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
apiToken: {
findMany: vi.fn(),
create: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { isApiGloballyEnabled } from "@/lib/app-settings";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/tokens", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
});
describe("POST /api/tokens — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Empty name (min 1) + `expiresInDays` above max (365).
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// Missing name + `expiresInDays` not a number + `expiresInDays` below min
// is hard since name and expiresInDays are the only knobs, so we use a
// name that is too long (max 100) + expiresInDays as a string and as
// a fraction (non-int).
const res = await POST(
postReq({
name: "x".repeat(200),
expiresInDays: "string",
// any extra unknown key Zod will silently strip; force a 3rd issue
// by including a stray nested shape that the schema doesn't accept.
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
// name too long + expiresInDays not a number = 2 issues at least.
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
it("writes a tokens.create.validation-failed audit row", async () => {
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("tokens.create.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
expect(res.status).toBe(422);
});
});
+15 -80
View File
@@ -1,23 +1,22 @@
/**
* `/api/tokens` — list and (via `[id]`) revoke a user's API tokens.
*
* The generic mint that used to live here (`POST`, minting
* `["medication:ingest"]`) was removed alongside the fail-closed scope
* default. That token could never do its advertised job — the external ingest
* surface gates on the per-medication `medication:<id>:ingest` grant, which
* this endpoint never issued — while the old fail-open default let it reach
* every other authenticated route. The working credential is minted by the
* per-medication API-endpoint toggle
* (`/api/medications/[id]/api-endpoint`), which issues both grants.
*
* Listing and revoking stay so existing tokens remain visible and revocable.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiSuccess,
apiError,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { hashToken } from "@/lib/auth/hmac";
import { apiSuccess, apiError } from "@/lib/api-response";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { randomBytes } from "node:crypto";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
const createTokenSchema = z.object({
name: z.string().min(1, "Name required").max(100),
expiresInDays: z.number().int().min(1).max(365).optional(),
});
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -43,67 +42,3 @@ export const GET = apiHandler(async () => {
return apiSuccess(tokens);
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "tokens.create" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createTokenSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422 + audit-ledger breadcrumb so an
// operator can grep `tokens.create.validation-failed` when a
// device sends a malformed mint payload.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "tokens.create.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// mint payload carries a free-text `name`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "tokens.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { name, expiresInDays } = parsed.data;
// Generate a random token
const rawToken = `hlk_${randomBytes(32).toString("hex")}`;
const tokenHashValue = hashToken(rawToken);
await prisma.apiToken.create({
data: {
userId: user.id,
name,
tokenHash: tokenHashValue,
permissions: ["medication:ingest"],
expiresAt: expiresInDays
? new Date(Date.now() + expiresInDays * 86400000)
: null,
},
});
// Return the raw token ONCE — it can never be retrieved again
return apiSuccess({ token: rawToken, name }, 201);
});
+10 -108
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ChevronDown, Copy, Key, Loader2, Trash2 } from "lucide-react";
import { ChevronDown, Key, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
@@ -18,7 +18,6 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -149,12 +148,7 @@ function ApiTokensCard() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [newName, setNewName] = useState("");
const [newToken, setNewToken] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
const [showRevokedTokens, setShowRevokedTokens] = useState(false);
const [tokenCopied, setTokenCopied] = useState(false);
const { data: tokens } = useQuery({
queryKey: queryKeys.tokens(),
@@ -164,47 +158,6 @@ function ApiTokensCard() {
enabled: isAuthenticated,
});
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setCreating(true);
setTokenMsg(null);
setNewToken(null);
try {
const res = await apiFetchRaw("/api/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
});
const json = await res.json();
if (res.ok) {
setNewToken(json.data.token);
setNewName("");
queryClient.invalidateQueries({ queryKey: queryKeys.tokens() });
} else {
setTokenMsg(json.error || t("common.error"));
}
} catch {
setTokenMsg(t("common.networkError"));
} finally {
setCreating(false);
}
}
async function handleCopyToken() {
if (!newToken) return;
try {
await navigator.clipboard.writeText(newToken);
setTokenCopied(true);
toast.success(t("settings.tokenCopied"));
// Revert the inline check affordance after a short beat so a
// second copy reads as a fresh action.
setTimeout(() => setTokenCopied(false), 2_000);
} catch {
toast.error(t("settings.tokenCopyFailed"));
}
}
async function handleRevoke(tokenId: string) {
try {
const res = await apiFetchRaw(`/api/tokens/${tokenId}`, {
@@ -254,66 +207,15 @@ function ApiTokensCard() {
/>
<div className="mt-4 space-y-4 pl-7">
{/* The button mirrors the Input's responsive height (h-11 / sm:h-10)
so the row reads as one aligned control group instead of a tall
input next to a shorter `sm` button. */}
<form onSubmit={handleCreate} className="flex items-center gap-2">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("settings.tokenNamePlaceholder")}
maxLength={100}
className="flex-1"
/>
<Button
type="submit"
variant="outline"
className="h-11 sm:h-10"
disabled={creating || !newName.trim()}
>
{creating && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.create")}
</Button>
</form>
{newToken && (
<div
className="bg-success/10 rounded-lg p-3 text-sm"
data-slot="settings-api-token-created"
>
<p className="text-success mb-1 font-medium">
{t("settings.tokenCreated")}
</p>
<div className="flex items-start gap-2">
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
{newToken}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
onClick={() => void handleCopyToken()}
aria-label={t("settings.tokenCopy")}
data-slot="settings-api-token-copy"
>
{tokenCopied ? (
<Check className="text-success h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
)}
{tokenMsg && (
<p role="alert" className="text-destructive text-sm">
{tokenMsg}
</p>
)}
{/* Tokens are no longer minted here. The generic mint issued a
`medication:ingest` token that the ingest endpoint refused (it
gates on the per-medication grant too) and that every other
authenticated route accepted. The per-medication API-endpoint
toggle issues the pair that actually works, scoped to one
medication. This card lists and revokes. */}
<p className="text-muted-foreground text-sm">
{t("settings.tokenMintMovedDescription")}
</p>
<div>
<p className="mb-2 text-sm font-medium">
+77 -78
View File
@@ -200,12 +200,17 @@ describe("requireAuth — Bearer token path", () => {
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
// v1.4.25 W10 reconcile (code-review H2): a route that does NOT declare
// a scope is content with any authenticated token. The previous policy
// 403'd every narrow-scope token here and broke `/api/measurements/by-
// external-ids`, `/api/personal-records`, `/api/medications/[id]/glp1`,
// and `/api/dashboard/glp1` — all four were called without a scope.
it("accepts a narrow-scope Bearer token when the route did not declare a scope", async () => {
// Supersedes the v1.4.25 "a route that declares no scope is content with any
// authenticated token" policy. That policy was adopted to unbreak four
// scopeless routes for narrow-token callers, and in doing so it handed every
// narrow token the full authenticated surface — the escalation this replaces.
// The right fix for a route that genuinely wants a narrow token is for that
// route to name the scope, which is a visible diff.
//
// The fail-closed default. Bare `requireAuth()` means "cookie session or
// cookie-equivalent token" — a narrow token is refused, and the audit row
// says why so an operator can name the token and its owner.
it("refuses a narrow-scope Bearer token when the route declared no scope", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
@@ -218,14 +223,40 @@ describe("requireAuth — Bearer token path", () => {
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const ctx = await requireAuth();
expect(ctx.user.id).toBe("user-1");
expect(ctx.session.id).toBe("token-5a");
// v1.25 — no per-request success audit row on the accept path.
expect(auditLog).not.toHaveBeenCalledWith(
"auth.bearer.success",
expect.anything(),
await expect(requireAuth()).rejects.toMatchObject({
statusCode: 403,
message: "Insufficient permissions",
} satisfies Partial<HttpError>);
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
reason: "undeclared_scope",
tokenId: "token-5a",
}),
}),
);
// The user row is never loaded — the deny happens on the token alone.
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("admits a narrow-scope Bearer token on a route that names its scope", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-5a2",
userId: "user-1",
permissions: ["fhir:read"],
revoked: false,
expiresAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const ctx = await requireAuth("fhir:read");
expect(ctx.user.id).toBe("user-1");
expect(ctx.session.id).toBe("token-5a2");
});
it("accepts a wildcard Bearer token when the route did not declare a scope", async () => {
@@ -273,10 +304,18 @@ describe("requireAuth — Bearer token path", () => {
});
});
describe("requireAuth — H1 MCP health:read audience binding on REST", () => {
// Drive the FULL apiHandler path so the request method is in event context —
// the H1 check reads it to allow safe reads but block writes.
const route = apiHandler(async (req: Request) => {
describe("requireAuth — MCP-audience tokens have no REST reach", () => {
// Before the fail-closed default, an MCP-audience token (`health:read`, or
// `health:read health:write`) was admitted on safe REST methods and refused
// on writes by the `isMcpAudienceToken` guard. It now carries no `*` grant
// and no REST route names `health:read`, so the resolver refuses it on EVERY
// method — reads included. Its audience narrows from "/mcp + REST reads" to
// "/mcp only", which is what RFC 8707 audience binding asked for.
//
// The `isMcpAudienceToken` guard is retained in `api-handler.ts` as defence
// in depth; it is unreachable on this path, which is exactly why these tests
// assert `undeclared_scope` rather than `mcp_audience_write_blocked`.
const route = apiHandler(async (req) => {
await requireAuth();
return Response.json({ ok: true, method: req.method });
});
@@ -295,41 +334,36 @@ describe("requireAuth — H1 MCP health:read audience binding on REST", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
});
function mockHealthReadToken(): void {
function mockMcpToken(permissions: string[]): void {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "mcp-token",
userId: "user-1",
permissions: ["health:read"],
permissions,
revoked: false,
expiresAt: null,
} as never);
}
it("accepts a health:read token on a GET read route", async () => {
mockHealthReadToken();
const res = await call("GET");
expect(res.status).toBe(200);
});
for (const permissions of [
["health:read"],
["health:read", "health:write"],
]) {
const label = permissions.join(" ");
it("rejects a health:read token on POST with 403", async () => {
mockHealthReadToken();
const res = await call("POST");
expect(res.status).toBe(403);
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({
reason: "mcp_audience_write_blocked",
}),
}),
);
});
it("rejects a health:read token on DELETE with 403", async () => {
mockHealthReadToken();
const res = await call("DELETE");
expect(res.status).toBe(403);
});
for (const method of ["GET", "POST", "DELETE"]) {
it(`refuses a ${label} token on REST ${method} with 403`, async () => {
mockMcpToken(permissions);
const res = await call(method);
expect(res.status).toBe(403);
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({ reason: "undeclared_scope" }),
}),
);
});
}
}
it("does NOT restrict a wildcard token on POST (not MCP-audience)", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
@@ -342,41 +376,6 @@ describe("requireAuth — H1 MCP health:read audience binding on REST", () => {
const res = await call("POST");
expect(res.status).toBe(200);
});
it("rejects a health:read+write token on POST with 403 (writes are /mcp-only)", async () => {
// A write-scoped MCP token is STILL audience-bound: its `health:write`
// grant admits the confirmed in-process `/mcp` write tools, never a REST
// write/delete. So it gets the same 403 as a read-only MCP token here.
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "rw",
userId: "user-1",
permissions: ["health:read", "health:write"],
revoked: false,
expiresAt: null,
} as never);
const res = await call("POST");
expect(res.status).toBe(403);
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({
reason: "mcp_audience_write_blocked",
}),
}),
);
});
it("accepts a health:read+write token on a GET read route", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "rw",
userId: "user-1",
permissions: ["health:read", "health:write"],
revoked: false,
expiresAt: null,
} as never);
const res = await call("GET");
expect(res.status).toBe(200);
});
});
describe("requireAuth — cookie path remains intact", () => {
+29 -7
View File
@@ -8,7 +8,11 @@ import { emitIfSampled } from "./logging/transports";
import { redactOptional, redactSecrets } from "./logging/redact";
import { getSession } from "./auth/session";
import { auditLog } from "./auth/audit";
import { resolveBearerToken, BearerAuthError } from "./auth/bearer";
import {
resolveBearerToken,
BearerAuthError,
type ScopeRequirement,
} from "./auth/bearer";
import { AssistantDisabledError } from "./feature-flags";
import { ConsentRequiredError } from "./ai/consent-guard";
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config";
@@ -295,8 +299,12 @@ export type AuthContext = {
* 3. Neither → 401.
*
* @param requiredPermission Optional permission scope. Only enforced for Bearer
* auth — cookie sessions always pass (full user access). When set and missing
* from `ApiToken.permissions`, throws HttpError(403).
* auth — cookie sessions always pass (full user access). Omitting it is a
* positive declaration: the route accepts cookie sessions and cookie-
* equivalent (`["*"]`) tokens ONLY, and refuses a narrow-scope token with
* HttpError(403). Naming a scope additionally admits narrow tokens that list
* it. A token that lists neither `*` nor the named scope throws
* HttpError(403).
*/
export async function requireAuth(
requiredPermission?: string,
@@ -346,17 +354,24 @@ export async function requireAuth(
* annotation, then maps the result onto the `AuthContext` contract
* (`session.id` carries the token id).
*
* Authorisation contract (unchanged): a route that declares no
* `requiredPermission` accepts any valid token; one that declares a scope
* accepts wildcard (`["*"]`) tokens and narrow-scope tokens that list it.
* Authorisation contract (fail-closed): a route that declares no
* `requiredPermission` accepts cookie sessions and cookie-equivalent (`["*"]`)
* tokens only — a narrow-scope token is refused 403 with an `undeclared_scope`
* audit row. A route that declares a scope additionally accepts narrow tokens
* that list it. The absence of an argument is a positive statement, not an
* omission, which is what makes the default safe for routes nobody has thought
* about yet.
*/
async function authenticateBearer(
rawToken: string,
requiredPermission: string | undefined,
): Promise<AuthContext> {
const requirement: ScopeRequirement = requiredPermission
? { kind: "scope", scope: requiredPermission }
: { kind: "wildcard-only" };
let resolution;
try {
resolution = await resolveBearerToken(rawToken, requiredPermission);
resolution = await resolveBearerToken(rawToken, requirement);
} catch (err) {
if (err instanceof BearerAuthError) {
auditLog("auth.bearer.failure", {
@@ -393,6 +408,13 @@ async function authenticateBearer(
// which always sets the method — an unknown method means we cannot prove a
// read, so we deny. This is RFC 8707 audience binding on the credential the
// client actually holds, not only during the OAuth exchange.
//
// Since the fail-closed scope default landed, this guard is unreachable on
// the deny path: an MCP-audience token carries no `*`, so `wildcard-only`
// already refused it in `resolveBearerToken` before we get here (an MCP
// token's REST reach is now nil, not "safe methods"). It is kept as defence
// in depth — it still holds the line if a future REST route ever declares
// `health:read` and so admits the token past the resolver.
if (isMcpAudienceToken(permissions)) {
const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase();
if (!READ_HTTP_METHODS.has(method)) {
+68 -8
View File
@@ -9,6 +9,7 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
import { resolveBearerToken, BearerAuthError } from "../bearer";
import type { ScopeRequirement } from "../bearer";
import { prisma } from "@/lib/db";
import { hashToken } from "@/lib/auth/hmac";
@@ -16,6 +17,12 @@ const FAKE_HASH = "deadbeefcafef00d";
const RAW_TOKEN = "hlk_" + "a".repeat(64);
const FAKE_USER = { id: "user-1", role: "USER" };
/** The REST default: cookie sessions and `["*"]` tokens only. */
const WILDCARD_ONLY: ScopeRequirement = { kind: "wildcard-only" };
/** The `/mcp` posture: authenticate here, authorise downstream. */
const ANY_VALID: ScopeRequirement = { kind: "any-valid-token" };
const scope = (s: string): ScopeRequirement => ({ kind: "scope", scope: s });
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(hashToken).mockReturnValue(FAKE_HASH);
@@ -33,7 +40,7 @@ describe("resolveBearerToken", () => {
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const res = await resolveBearerToken(RAW_TOKEN);
const res = await resolveBearerToken(RAW_TOKEN, WILDCARD_ONLY);
expect(hashToken).toHaveBeenCalledWith(RAW_TOKEN);
expect(prisma.apiToken.findUnique).toHaveBeenCalledWith(
@@ -50,7 +57,9 @@ describe("resolveBearerToken", () => {
it("rejects an unknown token (401)", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null as never);
await expect(resolveBearerToken(RAW_TOKEN)).rejects.toMatchObject({
await expect(
resolveBearerToken(RAW_TOKEN, WILDCARD_ONLY),
).rejects.toMatchObject({
statusCode: 401,
reason: "unknown_token",
} satisfies Partial<BearerAuthError>);
@@ -65,7 +74,9 @@ describe("resolveBearerToken", () => {
revoked: true,
expiresAt: null,
} as never);
await expect(resolveBearerToken(RAW_TOKEN)).rejects.toMatchObject({
await expect(
resolveBearerToken(RAW_TOKEN, WILDCARD_ONLY),
).rejects.toMatchObject({
statusCode: 401,
reason: "revoked",
});
@@ -79,7 +90,9 @@ describe("resolveBearerToken", () => {
revoked: false,
expiresAt: new Date(Date.now() - 60_000),
} as never);
await expect(resolveBearerToken(RAW_TOKEN)).rejects.toMatchObject({
await expect(
resolveBearerToken(RAW_TOKEN, WILDCARD_ONLY),
).rejects.toMatchObject({
statusCode: 401,
reason: "expired",
});
@@ -94,7 +107,7 @@ describe("resolveBearerToken", () => {
expiresAt: null,
} as never);
await expect(
resolveBearerToken(RAW_TOKEN, "health:write"),
resolveBearerToken(RAW_TOKEN, scope("health:write")),
).rejects.toMatchObject({
statusCode: 403,
reason: "insufficient_permissions",
@@ -102,7 +115,11 @@ describe("resolveBearerToken", () => {
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("admits any valid token when no permission is required (REQ-SEC-5)", async () => {
// The inversion. A narrow token used to be admitted by every route that
// named no scope — 324 of them — which is how a token minted for medication
// intake reached the full-backup export. `wildcard-only` is now the default
// arm, so the safe outcome is the one a route gets by doing nothing.
it("refuses a narrow-scope token under the wildcard-only default (403)", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-5",
userId: "user-1",
@@ -110,8 +127,51 @@ describe("resolveBearerToken", () => {
revoked: false,
expiresAt: null,
} as never);
await expect(
resolveBearerToken(RAW_TOKEN, WILDCARD_ONLY),
).rejects.toMatchObject({
statusCode: 403,
reason: "undeclared_scope",
});
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("admits a narrow-scope token for the scope it lists", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-5b",
userId: "user-1",
permissions: ["fhir:read"],
revoked: false,
expiresAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const res = await resolveBearerToken(RAW_TOKEN);
const res = await resolveBearerToken(RAW_TOKEN, scope("fhir:read"));
expect(res.user.id).toBe("user-1");
});
it("admits any valid token under the any-valid-token posture", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-5c",
userId: "user-1",
permissions: ["health:read"],
revoked: false,
expiresAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const res = await resolveBearerToken(RAW_TOKEN, ANY_VALID);
expect(res.user.id).toBe("user-1");
});
it("admits a wildcard token under the wildcard-only default", async () => {
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-5d",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const res = await resolveBearerToken(RAW_TOKEN, WILDCARD_ONLY);
expect(res.user.id).toBe("user-1");
});
@@ -124,7 +184,7 @@ describe("resolveBearerToken", () => {
expiresAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const res = await resolveBearerToken(RAW_TOKEN, "health:write");
const res = await resolveBearerToken(RAW_TOKEN, scope("health:write"));
expect(res.user.id).toBe("user-1");
});
});
+53 -19
View File
@@ -24,8 +24,29 @@ export type BearerAuthReason =
| "revoked"
| "expired"
| "insufficient_permissions"
| "undeclared_scope"
| "user_missing";
/**
* What a caller demands of a token's scope set. Required on every
* `resolveBearerToken` call — there is deliberately no default, so a new wire
* cannot re-open the fail-open hole by simply omitting the argument.
*
* - `scope` — the route names a grant; a non-wildcard token must list it.
* - `wildcard-only` — the REST default. A route that declares no scope accepts
* cookie sessions and cookie-equivalent (`["*"]`) tokens only. A narrow token
* is refused, so adding a route can never silently widen an existing token's
* reach.
* - `any-valid-token` — authentication only; authorisation is decided by the
* caller's own wire. This is the single deliberate fail-open posture in the
* tree (the `/mcp` transport, which gates on audience + `tokenAllowsWrite`
* downstream) and a structural test freezes it to exactly one call site.
*/
export type ScopeRequirement =
| { kind: "scope"; scope: string }
| { kind: "wildcard-only" }
| { kind: "any-valid-token" };
/**
* Thrown when a token fails validation. Carries the HTTP status the HTTP edge
* should surface plus the machine `reason` (and, when known, the owning
@@ -59,15 +80,15 @@ export interface BearerResolution {
* Resolve a raw `hlk_<hex>` token to its user, or throw `BearerAuthError`.
*
* Lookups are by HMAC hash only — the plaintext token is never compared against
* a stored value. When `requiredPermission` is set, a non-wildcard token must
* list it; an unset `requiredPermission` admits any valid token (matching the
* "route declares no scope ⇒ authentication alone is sufficient" contract).
* a stored value. Authorisation is fail-closed: `requirement` is mandatory and
* a token carrying no `*` grant is admitted only when the caller names a scope
* the token lists, or explicitly opts into `any-valid-token`.
*
* `lastUsedAt` is refreshed fire-and-forget on success.
*/
export async function resolveBearerToken(
rawToken: string,
requiredPermission?: string,
requirement: ScopeRequirement,
): Promise<BearerResolution> {
const tokenHashValue = hashToken(rawToken);
@@ -94,21 +115,34 @@ export async function resolveBearerToken(
throw new BearerAuthError(401, "expired", apiToken.userId, apiToken.id);
}
// `["*"]` is the wildcard scope. A route that declares no `requiredPermission`
// accepts any valid token; one that declares a scope accepts wildcard tokens
// and narrow-scope tokens that list that scope.
const hasWildcardPermission = apiToken.permissions.includes("*");
if (
requiredPermission &&
!hasWildcardPermission &&
!apiToken.permissions.includes(requiredPermission)
) {
throw new BearerAuthError(
403,
"insufficient_permissions",
apiToken.userId,
apiToken.id,
);
// `["*"]` is the wildcard scope — cookie-equivalent, and the only shape the
// login / passkey / refresh paths mint. It clears every requirement.
//
// A narrow token has to be named. `wildcard-only` (the REST default, reached
// by every route that passes no scope) refuses it outright; `scope` admits it
// only when the token lists exactly that grant. The deny arm is what a route
// gets by doing nothing, so a newly added route cannot widen an existing
// token's reach without a visible diff.
if (!apiToken.permissions.includes("*")) {
if (requirement.kind === "wildcard-only") {
throw new BearerAuthError(
403,
"undeclared_scope",
apiToken.userId,
apiToken.id,
);
}
if (
requirement.kind === "scope" &&
!apiToken.permissions.includes(requirement.scope)
) {
throw new BearerAuthError(
403,
"insufficient_permissions",
apiToken.userId,
apiToken.id,
);
}
}
const user = await prisma.user.findUnique({
+7 -1
View File
@@ -25,7 +25,13 @@ describe("resolveMcpAuthContext", () => {
const ctx = await resolveMcpAuthContext(" hlk_abc ");
// Trimmed token resolved through the shared validator (no requiredPermission for reads).
expect(resolveBearerToken).toHaveBeenCalledWith("hlk_abc");
// `any-valid-token` is the one deliberate fail-open posture in the tree:
// `/mcp` authenticates here and authorises downstream (audience binding +
// `tokenAllowsWrite`). Asserted explicitly so a silent switch to another
// posture — in either direction — shows up as a failing test.
expect(resolveBearerToken).toHaveBeenCalledWith("hlk_abc", {
kind: "any-valid-token",
});
expect(ctx.userId).toBe("user-1");
expect(ctx.tokenId).toBe("token-9");
expect(ctx.binding).toBe("user-1:token-9");
+10 -3
View File
@@ -36,8 +36,13 @@ export interface McpAuthContext {
/**
* Resolve a raw Bearer token into an `McpAuthContext`, or throw on an invalid /
* revoked / expired token. Reads require no special scope (the underlying reads
* are unscoped), so no `requiredPermission` is passed.
* revoked / expired token.
*
* This is the ONE deliberate `any-valid-token` posture in the tree: the `/mcp`
* transport authenticates here and authorises downstream — audience binding
* plus `tokenAllowsWrite` gate the write tools, so a scope check at this line
* would be the wrong layer. A structural test asserts this call site stays the
* only one, because every other wire must fail closed.
*/
export async function resolveMcpAuthContext(
rawToken: string,
@@ -47,7 +52,9 @@ export async function resolveMcpAuthContext(
throw new Error("Missing MCP Bearer token");
}
const { user, tokenId, permissions } = await resolveBearerToken(trimmed);
const { user, tokenId, permissions } = await resolveBearerToken(trimmed, {
kind: "any-valid-token",
});
return {
userId: user.id,
@@ -0,0 +1,322 @@
/**
* Behavioural suite for the fail-closed Bearer-scope default, against a real
* Postgres and the real resolver — no mocked Prisma, no mocked `requireAuth`.
*
* The gap this pins: `requireAuth()` enforced a token's scope only when the
* route passed one, and 324 of 330 route files pass none. A token minted for
* medication intake therefore reached the full-backup export, the labs surface,
* the coach, and the bulk deletes. B1 is the direct regression test — it fails
* on the pre-fix tree.
*
* Seven cases against 300-odd routes prove nothing about the other routes by
* enumeration. The guarantee for those is structural (one resolution path, one
* authorisation arm) and is held by
* `src/__tests__/bearer-scope-enforcement-guard.test.ts`. What these cases do
* is prove the arm itself behaves, end to end, for every credential shape the
* app actually mints.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.API_TOKEN_HMAC_KEY ??=
"test-hmac-key-bearer-scope-enforcement-32-bytes-min-0987654321";
const { hashToken } = await import("@/lib/auth/hmac");
const USER_ID = "user-bearer-scope-test";
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),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await getPrismaClient().user.create({
data: {
id: USER_ID,
username: "bearer-scope",
email: "bearer-scope@example.test",
timezone: "UTC",
},
});
});
/** Mint a real `ApiToken` row with the given scopes and arm the Bearer header. */
async function useToken(permissions: string[], label = "t"): Promise<string> {
const raw = `hlk_${label}_${"0".repeat(48)}`;
await getPrismaClient().apiToken.create({
data: {
userId: USER_ID,
name: label,
tokenHash: hashToken(raw),
permissions,
},
});
headerJar.set("authorization", `Bearer ${raw}`);
return raw;
}
/** Arm a real cookie session instead of a Bearer token. */
async function useCookieSession(): Promise<void> {
const session = await getPrismaClient().session.create({
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
}
function req(path: string, method = "GET"): NextRequest {
const init: RequestInit = { method };
const raw = headerJar.get("authorization");
if (raw) init.headers = { authorization: raw };
return new NextRequest(`https://health.example${path}`, init as never);
}
/** The most recent `auth.bearer.failure` audit row's machine reason. */
async function lastBearerFailureReason(): Promise<string | undefined> {
const row = await getPrismaClient().auditLog.findFirst({
where: { action: "auth.bearer.failure" },
orderBy: { createdAt: "desc" },
});
if (!row?.details) return undefined;
return (JSON.parse(row.details) as { reason?: string }).reason;
}
describe("B1 — a narrow token cannot read the full-account export", () => {
it("refuses ['medication:ingest'] on GET /api/export/full-backup with 403", async () => {
// The vulnerability in one request: this is the token the settings card
// told users to hand to third-party automations, reading every row the
// account holds.
await useToken(["medication:ingest"], "narrow");
const { GET } = await import("@/app/api/export/full-backup/route");
const res = await GET(req("/api/export/full-backup"));
expect(res.status).toBe(403);
const json = (await res.json()) as { data: null; error: string };
expect(json.data).toBeNull();
expect(json.error).toBe("Insufficient permissions");
// The break is visible AND attributable: an operator can name the token.
// Audit writes are fire-and-forget; give the microtask a beat to land.
await new Promise((r) => setTimeout(r, 100));
expect(await lastBearerFailureReason()).toBe("undeclared_scope");
});
it("refuses the same token on GET /api/labs with 403", async () => {
await useToken(["medication:ingest"], "narrow2");
const { GET } = await import("@/app/api/labs/route");
const res = await GET(req("/api/labs"));
expect(res.status).toBe(403);
});
});
describe("B2/B3 — the medication-ingest surface is unchanged", () => {
async function seedMedication(): Promise<string> {
const med = await getPrismaClient().medication.create({
data: { userId: USER_ID, name: "Test Med", dose: "1 mg" },
});
return med.id;
}
it("B2 — the per-medication token still ingests", async () => {
// The credential that actually performs medication intake. It never
// touches `requireAuth` — `/api/ingest/medication` hand-rolls the whole
// resolution — so the fail-closed default cannot reach it. This is the
// one flow a fail-closed default could plausibly have broken.
const medId = await seedMedication();
await useToken(["medication:ingest", `medication:${medId}:ingest`], "pair");
const { POST } = await import("@/app/api/ingest/medication/route");
const request = new NextRequest(
"https://health.example/api/ingest/medication",
{
method: "POST",
headers: {
authorization: headerJar.get("authorization")!,
"content-type": "application/json",
},
body: JSON.stringify({
medicationName: "Test Med",
idempotencyKey: "bearer-scope-b2",
}),
} as never,
);
const res = await POST(request);
expect([200, 201]).toContain(res.status);
const events = await getPrismaClient().medicationIntakeEvent.count({
where: { userId: USER_ID },
});
expect(events).toBe(1);
});
it("B3 — a token without the per-medication grant is still refused", async () => {
// The pre-existing second gate. This is why the retired `/api/tokens`
// mint never worked for its advertised purpose.
await seedMedication();
await useToken(["medication:ingest"], "familyonly");
const { POST } = await import("@/app/api/ingest/medication/route");
const request = new NextRequest(
"https://health.example/api/ingest/medication",
{
method: "POST",
headers: {
authorization: headerJar.get("authorization")!,
"content-type": "application/json",
},
body: JSON.stringify({
medicationName: "Test Med",
idempotencyKey: "bearer-scope-b3",
}),
} as never,
);
const res = await POST(request);
expect(res.status).toBe(403);
});
});
describe("B4 — the native client is not broken", () => {
it("admits a ['*'] token on GET /api/export/full-backup", 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.
await useToken(["*"], "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);
});
it("admits a ['*'] token on a batch ingest route", async () => {
await useToken(["*"], "wildcardbatch");
const { POST } = await import("@/app/api/measurements/batch/route");
const request = new NextRequest(
"https://health.example/api/measurements/batch",
{
method: "POST",
headers: {
authorization: headerJar.get("authorization")!,
"content-type": "application/json",
},
body: JSON.stringify({
entries: [
{
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
value: 70,
unit: "kg",
startDate: new Date().toISOString(),
endDate: new Date().toISOString(),
externalId: "uuid-bearer-scope-1",
},
],
}),
} as never,
);
const res = await POST(request);
expect(res.status).toBe(200);
});
});
describe("B5 — an MCP token is audience-bound to /mcp", () => {
it("refuses ['health:read'] on a REST read", async () => {
// Narrowing, not breakage: the token's REST read leg was never a feature
// an MCP client used. Its audience is now /mcp alone.
await useToken(["health:read"], "mcpread");
const { GET } = await import("@/app/api/export/full-backup/route");
const res = await GET(req("/api/export/full-backup"));
expect(res.status).toBe(403);
});
it("still resolves the same token on the /mcp wire", async () => {
const raw = await useToken(["health:read"], "mcpread2");
const { resolveMcpAuthContext } = await import("@/lib/mcp/auth");
const ctx = await resolveMcpAuthContext(raw);
expect(ctx.userId).toBe(USER_ID);
expect(ctx.canRead).toBe(true);
// Read-only token: the write tools stay shut.
expect(ctx.canWrite).toBe(false);
});
it("grants write on /mcp only for a consented health:write token", async () => {
const raw = await useToken(["health:read", "health:write"], "mcprw");
const { resolveMcpAuthContext } = await import("@/lib/mcp/auth");
const ctx = await resolveMcpAuthContext(raw);
expect(ctx.canWrite).toBe(true);
});
});
describe("B6 — a declared scope grants only what it names", () => {
it("admits ['fhir:read'] on the FHIR face", async () => {
await useToken(["fhir:read"], "fhir");
const { GET } = await import("@/app/api/fhir/Observation/route");
const res = await GET(req("/api/fhir/Observation"));
expect(res.status).toBe(200);
});
it("refuses the same token on /api/labs, which declares no scope", async () => {
// The whole point of the inversion: holding a scope buys the routes that
// name it, and nothing else.
await useToken(["fhir:read"], "fhir2");
const { GET } = await import("@/app/api/labs/route");
const res = await GET(req("/api/labs"));
expect(res.status).toBe(403);
});
});
describe("B7 — cookie sessions are untouched", () => {
it("reaches every route the narrow token was refused on", async () => {
await useCookieSession();
const backup = await import("@/app/api/export/full-backup/route");
expect((await backup.GET(req("/api/export/full-backup"))).status).toBe(200);
const labs = await import("@/app/api/labs/route");
expect((await labs.GET(req("/api/labs"))).status).toBe(200);
const fhir = await import("@/app/api/fhir/Observation/route");
expect((await fhir.GET(req("/api/fhir/Observation"))).status).toBe(200);
});
it("requireAdmin still refuses a ['*'] Bearer token", async () => {
// The cookie-only property of `requireAdmin()` is orthogonal to this
// change and must stay orthogonal. A wildcard token is the strongest
// Bearer credential the app mints and it still cannot elevate.
await getPrismaClient().user.update({
where: { id: USER_ID },
data: { role: "ADMIN" },
});
await useToken(["*"], "adminwild");
const { requireAdmin, HttpError } = await import("@/lib/api-handler");
await expect(requireAdmin()).rejects.toBeInstanceOf(HttpError);
});
});
+46 -13
View File
@@ -10,7 +10,8 @@
* - Over-cap routes return 400 with the documented error code
* - Idempotency-Key replay returns the cached envelope
* - Rate-limit kicks in at the documented ceiling
* - Narrow-scope Bearer tokens are admitted (W10 fix-C parity)
* - Narrow-scope Bearer tokens are refused; the wildcard token iOS
* actually holds is admitted
*
* A separate file at this directory carries the concurrent-write race
* test so the contention assertion stays focused on the invariant that
@@ -434,34 +435,66 @@ describe("POST /api/workouts/batch (real Postgres)", () => {
expect(stored).toBe(0);
});
it("admits a narrow-scope Bearer token (W10 fix-C parity)", async () => {
it("refuses a narrow-scope Bearer token, admits the wildcard one", async () => {
// Supersedes the W10 fix-C case, whose comment claimed "the iOS
// narrow-scope sync depends on" a narrow token being admitted here. It
// does not: `workouts:ingest` has no mint site anywhere in the tree, and
// iOS holds a `["*"]` token from login / passkey / refresh rotation. What
// that case actually pinned was the fail-open default — a token scoped to
// anything at all reaching a route that named no scope.
//
// Both halves are asserted together so the real iOS contract is the thing
// under test, not an inference from the deny case.
const { POST } = await import("@/app/api/workouts/batch/route");
// Drop the cookie session so the Bearer path is the ONLY auth in
// play. A narrow-scope token (no wildcard) MUST still admit on a
// route that declares no required permission — that's the W10
// fix-C correctness fix the iOS narrow-scope sync depends on.
// Drop the cookie session so the Bearer path is the ONLY auth in play.
cookieJar.delete("healthlog_session");
const rawToken = "hlk_narrow_scope_workout_ingest_token_value_a1b2c3d4e5";
const narrowToken =
"hlk_narrow_scope_workout_ingest_token_value_a1b2c3d4e5";
await getPrismaClient().apiToken.create({
data: {
userId: TEST_USER_ID,
name: "narrow-scope-workouts",
tokenHash: hashToken(rawToken),
tokenHash: hashToken(narrowToken),
permissions: ["workouts:ingest"], // NO wildcard
},
});
headerJar.set("authorization", `Bearer ${rawToken}`);
headerJar.set("authorization", `Bearer ${narrowToken}`);
const res = await POST(
const denied = await POST(
makeRequest(
{ workouts: [baseWorkout("hk-uuid-narrow-1")] },
{ bearer: rawToken },
{ bearer: narrowToken },
),
);
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { inserted: number } };
expect(denied.status).toBe(403);
expect(
await getPrismaClient().workout.count({
where: { userId: TEST_USER_ID },
}),
).toBe(0);
// The credential iOS actually holds.
const wildcardToken = "hlk_wildcard_workout_ingest_token_value_f6g7h8i9j0";
await getPrismaClient().apiToken.create({
data: {
userId: TEST_USER_ID,
name: "wildcard-workouts",
tokenHash: hashToken(wildcardToken),
permissions: ["*"],
},
});
headerJar.set("authorization", `Bearer ${wildcardToken}`);
const admitted = await POST(
makeRequest(
{ workouts: [baseWorkout("hk-uuid-wildcard-1")] },
{ bearer: wildcardToken },
),
);
expect(admitted.status).toBe(200);
const json = (await admitted.json()) as { data: { inserted: number } };
expect(json.data.inserted).toBe(1);
});
});