mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
6de61de010
commit
ddfe9170bd
11 files changed
+195
-19
No files matched your search
@@ -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)}
|
||||
|
||||
@@ -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("*");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user