feat(mcp): allow minting and consenting to health:write connector tokens

Let a user opt a connector token into logging. The settings card gains a
write toggle; the mint endpoint accepts a closed scope choice (read, or
read_write) and builds the permission array explicitly — it can mint only
[health:read] or [health:read, health:write], never a wildcard or any other
grant. The OAuth flow grants health:write only when a client requests it, and
the consent screen states in plain language that the app will be able to read
AND log/write health data when write is requested; the token endpoint grants
exactly the approved scopes and never widens. Read-only behaviour is
unchanged. Add the English and German strings for the new toggle and warning.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 17:38:51 +02:00
1 parent 6de61de010
commit ddfe9170bd
11 files changed
+195 -19

No files matched your search

+17 -2
View File
@@ -38,6 +38,7 @@ import {
isMcpOriginConfigured,
resolveBaseOrigin,
SCOPE_HEALTH_READ,
SCOPE_HEALTH_WRITE,
} from "@/lib/mcp/oauth/config";
import { redirectUriAllowed, resolveClient } from "@/lib/mcp/oauth/clients";
import { isValidChallenge } from "@/lib/mcp/oauth/pkce";
@@ -86,12 +87,19 @@ function html(body: string, status = 200): Response {
);
}
/** The single scope this read surface grants, plus optional refresh continuity. */
/**
* The scopes this surface grants for a request. `health:read` is always
* granted (the floor). `health:write` is granted ONLY when explicitly
* requested (RFC 8707 / incremental consent, SEP-835) — it admits the
* confirmed `/mcp` write tools and is surfaced plainly on the consent screen.
* `offline_access` enables refresh continuity. Nothing else is ever granted.
*/
function grantScope(requested: string | undefined): string {
const wants = new Set(
(requested ?? SCOPE_HEALTH_READ).split(/\s+/).filter(Boolean),
);
const granted = [SCOPE_HEALTH_READ];
if (wants.has(SCOPE_HEALTH_WRITE)) granted.push(SCOPE_HEALTH_WRITE);
if (wants.has("offline_access")) granted.push("offline_access");
return granted.join(" ");
}
@@ -299,12 +307,19 @@ export async function GET(request: NextRequest): Promise<Response> {
v.clientSource === "cimd"
? `<p>Verified origin: <code>${htmlEscape(v.clientOrigin)}</code></p>`
: `<p><strong>Unverified application</strong> (dynamically registered — the name above is self-reported and not verified).</p>`;
// Plain-language access summary. When write is granted, say so explicitly —
// the app will be able to READ and LOG/WRITE health data, not just read.
const writeGranted = v.scope.split(/\s+/).includes(SCOPE_HEALTH_WRITE);
const accessSummary = writeGranted
? `<p><strong>This grants read AND write access:</strong> the application will be able to read your own health records <strong>and log new measurements and mood entries</strong> to your account on your behalf. It cannot delete or change existing entries, edit medications, or reach admin functions.</p>`
: `<p>Scope: <code>${htmlEscape(v.scope)}</code> — read-only access to your own health records.</p>`;
return html(
`<main>
<h1>Authorize access</h1>
<p><strong>${htmlEscape(v.clientName)}</strong> is requesting access to your HealthLog data.</p>
${provenance}
<p>Scope: <code>${htmlEscape(v.scope)}</code> — read-only access to your own health records.</p>
<p>Scope: <code>${htmlEscape(v.scope)}</code></p>
${accessSummary}
<form method="POST" action="/api/mcp/oauth/authorize">
${hidden("response_type", "code")}
${hidden("client_id", v.clientId)}
+18 -3
View File
@@ -2,8 +2,11 @@
* Token endpoint (OAuth 2.1) — `authorization_code` + `refresh_token` grants.
*
* Exchanges the bridge AS's self-describing artifacts for a REAL `hlk_`
* `ApiToken` (the access token) minted through `issue-token.ts` with the narrow
* `health:read` scope (ADR-006). The access token is therefore an ordinary
* `ApiToken` (the access token) minted through `issue-token.ts` with EXACTLY
* the scopes the authorization code carried — `health:read`, or
* `health:read health:write` when the user consented to logging (ADR-006). A
* `health:write` token is still audience-bound to `/mcp`. The access token is
* therefore an ordinary
* Bearer the `/mcp` resolver already understands — bound to `<userId>:<tokenId>`
* (REQ-SEC-11), revocable, and short-lived. There is no token passthrough: the
* MCP layer never forwards a client-presented upstream credential.
@@ -42,6 +45,7 @@ import {
isMcpOriginConfigured,
REFRESH_TOKEN_TTL_DAYS,
SCOPE_HEALTH_READ,
SCOPE_HEALTH_WRITE,
} from "@/lib/mcp/oauth/config";
import { signArtifact, verifyArtifact } from "@/lib/mcp/oauth/artifacts";
import { verifyPkceS256 } from "@/lib/mcp/oauth/pkce";
@@ -124,10 +128,21 @@ async function mintTokenPair(args: {
connectionId?: string;
refreshJti?: string;
}): Promise<Response> {
// Grant EXACTLY the scopes the authorization code carried (read, or
// read+write). `health:read` is always present; `health:write` is added
// only when the user consented to it at the authorize step. The token is
// never widened beyond the approved grant, and `offline_access` is a
// refresh-continuity marker, not an API permission.
const grantedScopes = new Set(args.scope.split(/\s+/).filter(Boolean));
const permissions = [SCOPE_HEALTH_READ];
if (grantedScopes.has(SCOPE_HEALTH_WRITE)) {
permissions.push(SCOPE_HEALTH_WRITE);
}
const access = await issueApiToken({
userId: args.userId,
name: "MCP connector",
permissions: [SCOPE_HEALTH_READ],
permissions,
expiresInMinutes: ACCESS_TOKEN_TTL_MINUTES,
...(args.connectionId ? { mcpConnectionId: args.connectionId } : {}),
});
@@ -0,0 +1,95 @@
/**
* Guard: the MCP connector mint endpoint mints ONLY the two allowed scope
* shapes — `["health:read"]` (default) or `["health:read","health:write"]` —
* and never a wildcard or any other grant.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
apiToken: { findMany: 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/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/issue-token", () => ({
issueApiToken: vi.fn(),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({ get: () => undefined })),
}));
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { issueApiToken } from "@/lib/auth/issue-token";
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/mcp/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(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(issueApiToken).mockResolvedValue({
token: "hlk_abc",
tokenId: "tok-1",
name: "n",
expiresAt: new Date(),
} as never);
});
describe("POST /api/mcp/tokens — scope minting", () => {
it("defaults to read-only [health:read]", async () => {
const res = await POST(postReq({ name: "laptop" }));
expect(res.status).toBe(201);
expect(issueApiToken).toHaveBeenCalledWith(
expect.objectContaining({ permissions: ["health:read"] }),
);
});
it("mints read+write when scope=read_write", async () => {
const res = await POST(postReq({ name: "laptop", scope: "read_write" }));
expect(res.status).toBe(201);
expect(issueApiToken).toHaveBeenCalledWith(
expect.objectContaining({
permissions: ["health:read", "health:write"],
}),
);
});
it("rejects any scope outside the closed set (no wildcard path)", async () => {
for (const bad of ["*", "admin", "health:write", "write", "read_admin"]) {
vi.mocked(issueApiToken).mockClear();
const res = await POST(postReq({ name: "x", scope: bad }));
expect(res.status).toBe(422);
expect(issueApiToken).not.toHaveBeenCalled();
}
});
it("never mints a wildcard for any input", async () => {
await POST(postReq({ name: "a" }));
await POST(postReq({ name: "b", scope: "read_write" }));
for (const call of vi.mocked(issueApiToken).mock.calls) {
expect(call[0].permissions).not.toContain("*");
}
});
});
+24 -7
View File
@@ -8,9 +8,14 @@
* `health:read` scope automatically for cloud connectors. Both surface here so
* the user can see and revoke every credential that can read over MCP.
*
* `userId` is always narrowed from the session — never a body field. Scope is
* hardcoded `["health:read"]`, so this endpoint can never be coerced into
* minting a broader grant (no mass assignment).
* `userId` is always narrowed from the session — never a body field. The
* caller may choose ONE of exactly two shapes via `scope`: `read` →
* `["health:read"]` (default) or `read_write` → `["health:read",
* "health:write"]`. The `permissions` array is built explicitly from that
* closed choice, so this endpoint can never be coerced into minting a
* wildcard or any other grant (no mass assignment). A `health:write` token is
* still audience-bound to `/mcp` (the resource-server guard refuses it on
* every REST write) — it only admits the confirmed in-process write tools.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -27,11 +32,16 @@ import { auditLog } from "@/lib/auth/audit";
import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { prisma } from "@/lib/db";
import { SCOPE_HEALTH_READ } from "@/lib/mcp/oauth/config";
import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "@/lib/mcp/oauth/config";
const createSchema = z.object({
name: z.string().min(1, "Name required").max(100),
expiresInDays: z.number().int().min(1).max(365).optional(),
// The ONLY two scope shapes this endpoint will mint. `read` (default) is the
// least-privilege read token; `read_write` adds `health:write` so the
// confirmed `/mcp` write tools become available. Anything else is rejected
// by the enum — the endpoint can never mint a wildcard or arbitrary grant.
scope: z.enum(["read", "read_write"]).optional().default("read"),
});
export const GET = apiHandler(async () => {
@@ -84,17 +94,24 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
// Build the permission array explicitly from the closed scope choice —
// never spread, never wildcard. `read` → read-only; `read_write` → read plus
// the `/mcp`-audience-bound write scope.
const permissions =
parsed.data.scope === "read_write"
? [SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE]
: [SCOPE_HEALTH_READ];
const issued = await issueApiToken({
userId: user.id,
name: parsed.data.name,
// Dedicated, least-privilege MCP read scope — never wildcard, never write.
permissions: [SCOPE_HEALTH_READ],
permissions,
expiresInDays: parsed.data.expiresInDays ?? 90,
});
await auditLog("mcp.tokens.create", {
userId: user.id,
details: { tokenId: issued.tokenId, scope: SCOPE_HEALTH_READ },
details: { tokenId: issued.tokenId, scope: permissions.join(" ") },
});
// Return the raw token ONCE — it can never be retrieved again.
+23 -1
View File
@@ -257,6 +257,7 @@ function McpTokensCard() {
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [newName, setNewName] = useState("");
const [allowWrite, setAllowWrite] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
@@ -277,12 +278,17 @@ function McpTokensCard() {
const res = await apiFetchRaw("/api/mcp/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim() }),
body: JSON.stringify({
name: newName.trim(),
// Closed choice — read-only, or read + the audience-bound write scope.
scope: allowWrite ? "read_write" : "read",
}),
});
const json = await res.json();
if (res.ok) {
setNewToken(json.data.token);
setNewName("");
setAllowWrite(false);
queryClient.invalidateQueries({ queryKey: queryKeys.mcpTokens() });
} else {
setTokenMsg(json.error || t("common.error"));
@@ -336,6 +342,22 @@ function McpTokensCard() {
{t("settings.mcp.scopeNote")}
</p>
<div className="border-border bg-muted/30 space-y-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">
{t("settings.mcp.writeScopeToggleLabel")}
</span>
<Switch
checked={allowWrite}
onCheckedChange={setAllowWrite}
aria-label={t("settings.mcp.writeScopeToggleLabel")}
/>
</div>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("settings.mcp.writeScopeNote")}
</p>
</div>
<form onSubmit={handleCreate} className="flex items-center gap-2">
<Input
value={newName}