From df611227f996eeb509af5530c67f29f4928a994a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 00:44:10 +0200 Subject: [PATCH 1/4] fix(auth): make Bearer scope fail closed on every route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveBearerToken took an optional permission, and an unset one admitted any valid token. 324 of the 330 route files pass no permission, so a token minted with a narrow scope reached the full-backup export, the labs and measurement surfaces, the coach, and the bulk deletes — the settings copy tells users to hand exactly that token to third-party automations. Invert the default. The scope argument becomes a required ScopeRequirement union: a route that names no scope now accepts cookie sessions and cookie-equivalent ["*"] tokens only, and refuses a narrow token with 403 and an undeclared_scope audit row. A route that means to accept a narrow token names it, as the FHIR routes already do. No route file changes. The absence of an argument becomes a positive statement rather than an omission, so a newly added route denies a newly minted narrow scope by default — the safe outcome is the one you get by doing nothing. /mcp keeps its own posture through an explicit any-valid-token requirement: it authenticates here and authorises downstream on audience and write scope. That is the single deliberate fail-open call site in the tree, and making the parameter required is what keeps it greppable. An MCP-audience token loses its REST read leg as a result, narrowing it to /mcp only. The isMcpAudienceToken guard stays as defence in depth. --- src/lib/__tests__/require-auth-bearer.test.ts | 155 +++++++++--------- src/lib/api-handler.ts | 36 +++- src/lib/auth/__tests__/bearer.test.ts | 76 ++++++++- src/lib/auth/bearer.ts | 72 +++++--- src/lib/mcp/__tests__/auth.test.ts | 8 +- src/lib/mcp/auth.ts | 13 +- 6 files changed, 244 insertions(+), 116 deletions(-) diff --git a/src/lib/__tests__/require-auth-bearer.test.ts b/src/lib/__tests__/require-auth-bearer.test.ts index 21af979a5..5faecb60d 100644 --- a/src/lib/__tests__/require-auth-bearer.test.ts +++ b/src/lib/__tests__/require-auth-bearer.test.ts @@ -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); + 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", () => { diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 3de3383e9..850c0dca3 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -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 { + 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)) { diff --git a/src/lib/auth/__tests__/bearer.test.ts b/src/lib/auth/__tests__/bearer.test.ts index f3dc89159..734c029e0 100644 --- a/src/lib/auth/__tests__/bearer.test.ts +++ b/src/lib/auth/__tests__/bearer.test.ts @@ -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); @@ -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"); }); }); diff --git a/src/lib/auth/bearer.ts b/src/lib/auth/bearer.ts index 694f27f14..8abb7997f 100644 --- a/src/lib/auth/bearer.ts +++ b/src/lib/auth/bearer.ts @@ -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_` 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 { 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({ diff --git a/src/lib/mcp/__tests__/auth.test.ts b/src/lib/mcp/__tests__/auth.test.ts index c73861031..a1164f24f 100644 --- a/src/lib/mcp/__tests__/auth.test.ts +++ b/src/lib/mcp/__tests__/auth.test.ts @@ -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"); diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts index 91eccc9c2..7ed23f852 100644 --- a/src/lib/mcp/auth.ts +++ b/src/lib/mcp/auth.ts @@ -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, From 8012aa095b0b3a1b435ff4e62f775b9e6c2defef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 00:51:29 +0200 Subject: [PATCH 2/4] feat(tokens): retire the generic API-token mint POST /api/tokens minted ["medication:ingest"], and that token could never do the job the settings copy advertised: /api/ingest/medication gates on the per-medication medication::ingest grant as well, which this endpoint never issued. It was refused at the ingest surface and admitted everywhere else. With Bearer scope now fail-closed the token is inert rather than dangerous, which is a worse thing to hand a user than the bug it replaces. Remove the mint. The working credential comes from the per-medication API-endpoint toggle, which issues both grants scoped to one medication. List and revoke stay so existing tokens remain visible and revocable, and the settings card now points at the per-medication toggle instead of offering a name field. Six locales updated. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../bearer-scope-enforcement-guard.test.ts | 195 ++++++++++++++++++ src/app/api/tokens/__tests__/route.test.ts | 120 ----------- src/app/api/tokens/route.ts | 95 ++------- src/components/settings/api-section.tsx | 118 +---------- 10 files changed, 232 insertions(+), 314 deletions(-) create mode 100644 src/__tests__/bearer-scope-enforcement-guard.test.ts delete mode 100644 src/app/api/tokens/__tests__/route.test.ts diff --git a/messages/de.json b/messages/de.json index c638bd350..12a12c74c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 6ea7a84ed..3f4f70771 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index a9b18f535..8d7e0c8fb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 409c462fe..cd66a28a8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index f2a132f72..303022426 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index c64233093..22cdd6c6f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/__tests__/bearer-scope-enforcement-guard.test.ts b/src/__tests__/bearer-scope-enforcement-guard.test.ts new file mode 100644 index 000000000..21c938fdc --- /dev/null +++ b/src/__tests__/bearer-scope-enforcement-guard.test.ts @@ -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::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 = { + // 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( + /(? { + // 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(); + for (const rel of sourceFiles()) { + for (const m of read(rel).matchAll( + /(? ({ - 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); - }); -}); diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts index cde3bc73a..541e0bf02 100644 --- a/src/app/api/tokens/route.ts +++ b/src/app/api/tokens/route.ts @@ -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::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); -}); diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 7e930bd03..b86dd1933 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -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(null); - const [creating, setCreating] = useState(false); - const [tokenMsg, setTokenMsg] = useState(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() { />
- {/* 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. */} -
- setNewName(e.target.value)} - placeholder={t("settings.tokenNamePlaceholder")} - maxLength={100} - className="flex-1" - /> - -
- - {newToken && ( -
-

- {t("settings.tokenCreated")} -

-
- - {newToken} - - -
-
- )} - - {tokenMsg && ( -

- {tokenMsg} -

- )} + {/* 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. */} +

+ {t("settings.tokenMintMovedDescription")} +

From ff6956decbae462b38bc73bbfd983834564d029d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 01:07:43 +0200 Subject: [PATCH 3/4] test(auth): pin the fail-closed Bearer-scope contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four structural guards freeze the invariants the change rests on: the set of files that can resolve a Bearer token to a user, the single deliberate any-valid-token posture, the mint sites with their scope sets, and the rule that a declared scope is an exported constant rather than a literal. Thirteen behavioural cases run against a real Postgres and the real resolver. Four of them go red on the pre-fix tree — a narrow token reading the full-backup export and the labs surface, and an MCP token on a REST read. The rest pin the flows that must NOT change: the per-medication ingest pair still works, the token without the per-medication grant is still refused, a wildcard token still reaches the export and the batch ingest, an MCP token still resolves on /mcp with its write scope intact, a declared fhir:read still opens the FHIR face and nothing else, cookie sessions are unaffected, and requireAdmin still refuses a wildcard Bearer. The workout-batch case that asserted narrow tokens are admitted claimed the iOS sync depended on it. It does not: workouts:ingest has no mint site anywhere, and iOS holds a ["*"] token. Replaced with a case that asserts both halves of the real contract. Documentation updated for the enforcement rule and the retired mint. --- CHANGELOG.md | 7 + CLAUDE.md | 3 +- docs/self-hosting/mcp.md | 8 +- .../bearer-scope-enforcement.test.ts | 322 ++++++++++++++++++ .../integration/workout-batch-create.test.ts | 59 +++- 5 files changed, 381 insertions(+), 18 deletions(-) create mode 100644 tests/integration/bearer-scope-enforcement.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 76bea5ed6..709010fdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +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. + ## [1.30.16] — 2026-07-19 Stops an ongoing loss of resting heart-rate history. diff --git a/CLAUDE.md b/CLAUDE.md index 0520b6acf..ac5b3c414 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/self-hosting/mcp.md b/docs/self-hosting/mcp.md index ae94a55ae..db632db0b 100644 --- a/docs/self-hosting/mcp.md +++ b/docs/self-hosting/mcp.md @@ -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 diff --git a/tests/integration/bearer-scope-enforcement.test.ts b/tests/integration/bearer-scope-enforcement.test.ts new file mode 100644 index 000000000..2b158e1e2 --- /dev/null +++ b/tests/integration/bearer-scope-enforcement.test.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + }); +}); diff --git a/tests/integration/workout-batch-create.test.ts b/tests/integration/workout-batch-create.test.ts index e4f03aca1..556e5d634 100644 --- a/tests/integration/workout-batch-create.test.ts +++ b/tests/integration/workout-batch-create.test.ts @@ -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); }); }); From 586c4318ca766ac7e4eb3f1555df04511e79db01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 01:13:11 +0200 Subject: [PATCH 4/4] =?UTF-8?q?chore(release):=20v1.30.17=20=E2=80=94=20en?= =?UTF-8?q?force=20Bearer=20token=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 709010fdd..1a74de650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [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. @@ -9,6 +11,8 @@ API tokens now enforce their scope. - **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. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bcbe4e279..de144d607 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/package.json b/package.json index 788a1f387..12bd2833f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index abcee3e04..2c53bbd78 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*`