mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
24 files changed
+864
-451
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user