mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
merge: bring the MCP server onto the v1.23 base
Combine the remote MCP server (stdio + remote /mcp + OAuth 2.1 + read tools + deep-value reads + a confirmed, audience-bound write surface) with the v1.23 account-security line (MFA, known/trusted devices, free-text note encryption). - schema: keep every v1.23 User relation (MFA, known/trusted devices) alongside the McpOAuthConnection relation; the MeasurementSource enum carries the MCP value. - api-handler: the MCP audience guard (a token scoped to health:read/health:write is bound to /mcp and refused a REST write) and the step-up MFA helpers coexist. - maintenance queues: register the MCP token-cleanup sweep next to the note-encryption backfill and key-rotation queues; shift it to 04:00 so the two daily discovery ticks do not collide. - migrations: renumber the MCP migrations to 0206/0207 so they follow the v1.23 chain (0199-0205). - MCP mood writes route the note through the v1.23 at-rest cipher.
This commit is contained in:
commit
fb986bc09d
89 files changed
+10276
-109
No files matched your search
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* OAuth discovery documents — M1 fail-closed + non-cacheable.
|
||||
*
|
||||
* Without a pinned origin the documents would derive the issuer / endpoints /
|
||||
* audience from the Host header, so the routes 404. When configured they must
|
||||
* not be `public`-cacheable (a host-derived document keyed on path alone could
|
||||
* be served cross-tenant).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const PRM_URL = "https://health.example/.well-known/oauth-protected-resource";
|
||||
const ASM_URL = "https://health.example/.well-known/oauth-authorization-server";
|
||||
|
||||
let GET_PRM: (r: Request) => Response;
|
||||
let GET_ASM: (r: Request) => Response;
|
||||
|
||||
beforeEach(async () => {
|
||||
GET_PRM = (await import("../oauth-protected-resource/route")).GET;
|
||||
GET_ASM = (await import("../oauth-authorization-server/route")).GET;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.APP_URL;
|
||||
delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
});
|
||||
|
||||
describe("OAuth discovery — M1 origin config", () => {
|
||||
it("404s the PRM when no origin is configured", () => {
|
||||
delete process.env.APP_URL;
|
||||
delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
const res = GET_PRM(new Request(PRM_URL));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s the AS metadata when no origin is configured", () => {
|
||||
delete process.env.APP_URL;
|
||||
delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
const res = GET_ASM(new Request(ASM_URL));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("serves the PRM with private/no-store when configured", async () => {
|
||||
process.env.APP_URL = "https://health.example";
|
||||
const res = GET_PRM(new Request(PRM_URL));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("cache-control")).toBe("private, no-store");
|
||||
const body = await res.json();
|
||||
expect(body.resource).toBe("https://health.example/mcp");
|
||||
});
|
||||
|
||||
it("serves the AS metadata with private/no-store when configured", async () => {
|
||||
process.env.APP_URL = "https://health.example";
|
||||
const res = GET_ASM(new Request(ASM_URL));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("cache-control")).toBe("private, no-store");
|
||||
const body = await res.json();
|
||||
expect(body.issuer).toBe("https://health.example");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* RFC 8414 — OAuth Authorization Server Metadata for the built-in bridge AS.
|
||||
*
|
||||
* Public, unauthenticated discovery (added to the proxy `.well-known` allowlist).
|
||||
* Advertises the authorize / token / register endpoints, `S256`-only PKCE, and
|
||||
* CIMD support (`client_id_metadata_document_supported: true` + `none` auth) so
|
||||
* Claude.ai / ChatGPT can self-register and complete the OAuth 2.1 + PKCE flow.
|
||||
*/
|
||||
import { authorizationServerMetadata } from "@/lib/mcp/oauth/metadata";
|
||||
import { isMcpOriginConfigured } from "@/lib/mcp/oauth/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET(request: Request): Response {
|
||||
// M1 — fail closed when no origin is pinned; never derive the issuer /
|
||||
// endpoint URLs from the attacker-influenceable Host header.
|
||||
if (!isMcpOriginConfigured()) {
|
||||
return Response.json(
|
||||
{ error: "not_found" },
|
||||
{ status: 404, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
return Response.json(authorizationServerMetadata(request.url), {
|
||||
// M1 — `private, no-store`: the document is host-derived, so a shared cache
|
||||
// keyed on path (ignoring Host) must never serve it cross-tenant.
|
||||
headers: { "Cache-Control": "private, no-store" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* RFC 9728 — OAuth Protected Resource Metadata for the `/mcp` resource.
|
||||
*
|
||||
* Public, unauthenticated discovery (added to the proxy `.well-known` allowlist).
|
||||
* A remote MCP client reads this after the `/mcp` 401 to learn which
|
||||
* Authorization Server backs the resource and what scopes it accepts. The
|
||||
* `resource` value is the canonical `/mcp` URI and MUST match the URL the user
|
||||
* pasted into Claude.ai / ChatGPT.
|
||||
*/
|
||||
import { protectedResourceMetadata } from "@/lib/mcp/oauth/metadata";
|
||||
import { isMcpOriginConfigured } from "@/lib/mcp/oauth/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET(request: Request): Response {
|
||||
// M1 — fail closed when no origin is pinned; never derive the `resource` /
|
||||
// authorization-server URLs from the attacker-influenceable Host header.
|
||||
if (!isMcpOriginConfigured()) {
|
||||
return Response.json(
|
||||
{ error: "not_found" },
|
||||
{ status: 404, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
return Response.json(protectedResourceMetadata(request.url), {
|
||||
// M1 — `private, no-store`: the document is host-derived, so a shared cache
|
||||
// keyed on path (ignoring Host) must never serve it cross-tenant.
|
||||
headers: { "Cache-Control": "private, no-store" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Revoke an MCP OAuth connection (H2).
|
||||
*
|
||||
* Revoking a connection terminates its whole refresh chain: it stamps
|
||||
* `revokedAt` (so every future refresh fails) and revokes every access token
|
||||
* the connection ever issued. Ownership is enforced inside
|
||||
* `revokeConnectionForUser`, so a connection id alone cannot revoke another
|
||||
* user's connection.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { revokeConnectionForUser } from "@/lib/mcp/oauth/connections";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: Request, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "mcp.connections.revoke" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const revoked = await revokeConnectionForUser(user.id, id);
|
||||
if (!revoked) {
|
||||
return apiError("Connection not found", 404);
|
||||
}
|
||||
|
||||
await auditLog("mcp.connections.revoke", {
|
||||
userId: user.id,
|
||||
details: { connectionId: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ revoked: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* MCP OAuth connections — list (H2).
|
||||
*
|
||||
* A connection is the revocable unit for a remote connector authorized through
|
||||
* the OAuth bridge (Claude.ai / ChatGPT / a third-party client). Unlike the
|
||||
* transient 60-minute access-token rows, the connection persists across
|
||||
* refreshes, so revoking it terminates the whole refresh chain. `userId` is
|
||||
* always narrowed from the session — never a body field.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { listConnectionsForUser } from "@/lib/mcp/oauth/connections";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "mcp.connections.list" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const connections = await listConnectionsForUser(user.id);
|
||||
return apiSuccess(connections);
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Authorize endpoint — consent gate + audience + PKCE + redirect-URI binding.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
|
||||
process.env.APP_URL = "https://health.example";
|
||||
|
||||
vi.mock("@/lib/logging/background", () => ({
|
||||
withBackgroundEvent: (_n: string, fn: () => unknown) => fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
ip: "1.2.3.4",
|
||||
})),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
isApiGloballyEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../authorize/route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { registerDcrClient } from "@/lib/mcp/oauth/clients";
|
||||
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
|
||||
|
||||
const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
|
||||
const VERIFIER = "a".repeat(64);
|
||||
const CHALLENGE = s256Challenge(VERIFIER);
|
||||
const RESOURCE = "https://health.example/mcp";
|
||||
|
||||
const CLIENT = registerDcrClient({
|
||||
clientName: "Claude",
|
||||
redirectUris: [REDIRECT],
|
||||
});
|
||||
|
||||
function authorizeUrl(
|
||||
overrides: Record<string, string | undefined> = {},
|
||||
): string {
|
||||
const params: Record<string, string | undefined> = {
|
||||
response_type: "code",
|
||||
client_id: CLIENT.clientId,
|
||||
redirect_uri: REDIRECT,
|
||||
code_challenge: CHALLENGE,
|
||||
code_challenge_method: "S256",
|
||||
scope: "health:read",
|
||||
state: "xyz",
|
||||
resource: RESOURCE,
|
||||
...overrides,
|
||||
};
|
||||
const u = new URL("https://health.example/api/mcp/oauth/authorize");
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined) u.searchParams.set(k, v);
|
||||
}
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
function getReq(url: string): Request {
|
||||
return new Request(url, { method: "GET" });
|
||||
}
|
||||
|
||||
function postReq(fields: Record<string, string>): Request {
|
||||
const body = new URLSearchParams(fields);
|
||||
return new Request("https://health.example/api/mcp/oauth/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
function signedIn() {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 1e6) },
|
||||
user: { id: "user-1" } as never,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("GET /authorize — consent gate", () => {
|
||||
it("renders a consent screen for an authenticated user (no code issued)", async () => {
|
||||
signedIn();
|
||||
const res = await GET(getReq(authorizeUrl()) as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toMatch(/Authorize access/);
|
||||
expect(body).toMatch(/Claude/);
|
||||
expect(body).toMatch(/decision/); // the Allow/Deny form
|
||||
// A GET never mints a code.
|
||||
expect(body).not.toMatch(/hlac_/);
|
||||
});
|
||||
|
||||
it("prompts sign-in (with a return link) when there is no session", async () => {
|
||||
const res = await GET(getReq(authorizeUrl()) as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toMatch(/\/auth\/login/);
|
||||
expect(body).not.toMatch(/hlac_/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /authorize — rejected requests", () => {
|
||||
it("rejects an audience mismatch with 400 invalid_target", async () => {
|
||||
signedIn();
|
||||
const res = await GET(
|
||||
getReq(authorizeUrl({ resource: "https://evil.example/mcp" })) as never,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("invalid_target");
|
||||
});
|
||||
|
||||
it("rejects a missing PKCE challenge with 400", async () => {
|
||||
signedIn();
|
||||
const res = await GET(
|
||||
getReq(
|
||||
authorizeUrl({
|
||||
code_challenge: undefined,
|
||||
code_challenge_method: undefined,
|
||||
}),
|
||||
) as never,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects a non-S256 PKCE method with 400", async () => {
|
||||
signedIn();
|
||||
const res = await GET(
|
||||
getReq(authorizeUrl({ code_challenge_method: "plain" })) as never,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects an unknown client with 400 invalid_client", async () => {
|
||||
signedIn();
|
||||
const res = await GET(
|
||||
getReq(authorizeUrl({ client_id: "bogus" })) as never,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toBe("invalid_client");
|
||||
});
|
||||
|
||||
it("rejects a redirect_uri not registered to the client", async () => {
|
||||
signedIn();
|
||||
const res = await GET(
|
||||
getReq(
|
||||
authorizeUrl({ redirect_uri: "https://evil.example/cb" }),
|
||||
) as never,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /authorize — consent provenance (L1)", () => {
|
||||
it("labels a DCR client as unverified (self-asserted name)", async () => {
|
||||
signedIn();
|
||||
const res = await GET(getReq(authorizeUrl()) as never);
|
||||
const body = await res.text();
|
||||
expect(body).toMatch(/Unverified application/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authorize — surface availability (M1/M4)", () => {
|
||||
it("GET returns 503 when the API is globally disabled", async () => {
|
||||
signedIn();
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
|
||||
const res = await GET(getReq(authorizeUrl()) as never);
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("POST returns 503 when the API is globally disabled", async () => {
|
||||
signedIn();
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
|
||||
const res = await POST(
|
||||
postReq({
|
||||
response_type: "code",
|
||||
client_id: CLIENT.clientId,
|
||||
redirect_uri: REDIRECT,
|
||||
code_challenge: CHALLENGE,
|
||||
code_challenge_method: "S256",
|
||||
scope: "health:read",
|
||||
resource: RESOURCE,
|
||||
decision: "allow",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /authorize — cross-origin guard (L3)", () => {
|
||||
it("rejects a cross-origin Origin with 403", async () => {
|
||||
signedIn();
|
||||
const body = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: CLIENT.clientId,
|
||||
redirect_uri: REDIRECT,
|
||||
code_challenge: CHALLENGE,
|
||||
code_challenge_method: "S256",
|
||||
scope: "health:read",
|
||||
resource: RESOURCE,
|
||||
decision: "allow",
|
||||
});
|
||||
const req = new Request("https://health.example/api/mcp/oauth/authorize", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
origin: "https://evil.example",
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
const res = await POST(req as never);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects a cross-site Sec-Fetch-Site with 403", async () => {
|
||||
signedIn();
|
||||
const body = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: CLIENT.clientId,
|
||||
redirect_uri: REDIRECT,
|
||||
code_challenge: CHALLENGE,
|
||||
code_challenge_method: "S256",
|
||||
scope: "health:read",
|
||||
resource: RESOURCE,
|
||||
decision: "allow",
|
||||
});
|
||||
const req = new Request("https://health.example/api/mcp/oauth/authorize", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
"sec-fetch-site": "cross-site",
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
const res = await POST(req as never);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /authorize — decision", () => {
|
||||
it("requires a session (401 when unauthenticated)", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
response_type: "code",
|
||||
client_id: CLIENT.clientId,
|
||||
redirect_uri: REDIRECT,
|
||||
code_challenge: CHALLENGE,
|
||||
code_challenge_method: "S256",
|
||||
scope: "health:read",
|
||||
resource: RESOURCE,
|
||||
decision: "allow",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("mints a code and 302s back on allow", async () => {
|
||||
signedIn();
|
||||
const res = await POST(
|
||||
postReq({
|
||||
response_type: "code",
|
||||
client_id: CLIENT.clientId,
|
||||
redirect_uri: REDIRECT,
|
||||
code_challenge: CHALLENGE,
|
||||
code_challenge_method: "S256",
|
||||
scope: "health:read",
|
||||
state: "xyz",
|
||||
resource: RESOURCE,
|
||||
decision: "allow",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
const loc = res.headers.get("location") ?? "";
|
||||
expect(loc.startsWith(REDIRECT)).toBe(true);
|
||||
const code = new URL(loc).searchParams.get("code");
|
||||
expect(code).toMatch(/^hlac_/);
|
||||
expect(new URL(loc).searchParams.get("state")).toBe("xyz");
|
||||
});
|
||||
|
||||
it("302s with access_denied on deny", async () => {
|
||||
signedIn();
|
||||
const res = await POST(
|
||||
postReq({
|
||||
response_type: "code",
|
||||
client_id: CLIENT.clientId,
|
||||
redirect_uri: REDIRECT,
|
||||
code_challenge: CHALLENGE,
|
||||
code_challenge_method: "S256",
|
||||
scope: "health:read",
|
||||
resource: RESOURCE,
|
||||
decision: "deny",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
const loc = res.headers.get("location") ?? "";
|
||||
expect(new URL(loc).searchParams.get("error")).toBe("access_denied");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* DCR registration (RFC 7591) — stateless `hlc_` client-id issuance.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
|
||||
|
||||
vi.mock("@/lib/logging/background", () => ({
|
||||
withBackgroundEvent: (_n: string, fn: () => unknown) => fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
remaining: 19,
|
||||
resetAt: Date.now() + 60_000,
|
||||
ip: "1.2.3.4",
|
||||
})),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
isApiGloballyEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import { POST } from "../register/route";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
|
||||
function jsonReq(body: unknown): Request {
|
||||
return new Request("https://health.example/api/mcp/oauth/register", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe("POST /register", () => {
|
||||
it("registers a public client and returns an hlc_ client_id", async () => {
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
client_name: "ChatGPT",
|
||||
redirect_uris: [
|
||||
"https://chatgpt.com/connector_platform_oauth_redirect",
|
||||
],
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.client_id).toMatch(/^hlc_/);
|
||||
expect(body.token_endpoint_auth_method).toBe("none");
|
||||
expect(body.redirect_uris).toEqual([
|
||||
"https://chatgpt.com/connector_platform_oauth_redirect",
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts a loopback redirect (Claude Code)", async () => {
|
||||
const res = await POST(
|
||||
jsonReq({ redirect_uris: ["http://127.0.0.1:8976/callback"] }) as never,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("rejects a missing redirect_uris with invalid_client_metadata", async () => {
|
||||
const res = await POST(jsonReq({ client_name: "x" }) as never);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toBe("invalid_client_metadata");
|
||||
});
|
||||
|
||||
it("rejects a non-https, non-loopback redirect_uri", async () => {
|
||||
const res = await POST(
|
||||
jsonReq({ redirect_uris: ["http://evil.example/cb"] }) as never,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 503 when the API is globally disabled (M4)", async () => {
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
|
||||
const res = await POST(
|
||||
jsonReq({ redirect_uris: ["https://chatgpt.com/cb"] }) as never,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
expect((await res.json()).error).toBe("temporarily_unavailable");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Token endpoint — PKCE verification, audience binding, single-use codes,
|
||||
* refresh-token rotation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY = "x".repeat(48);
|
||||
process.env.APP_URL = "https://health.example";
|
||||
|
||||
vi.mock("@/lib/logging/background", () => ({
|
||||
withBackgroundEvent: (_n: string, fn: () => unknown) => fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/auth/issue-token", () => ({
|
||||
issueApiToken: vi.fn(async () => ({
|
||||
token: "hlk_" + "a".repeat(64),
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
tokenId: "tok-1",
|
||||
name: "MCP connector",
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
remaining: 100,
|
||||
resetAt: Date.now() + 60_000,
|
||||
ip: "1.2.3.4",
|
||||
})),
|
||||
checkRateLimit: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
})),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
isApiGloballyEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/mcp/oauth/connections", () => ({
|
||||
createConnection: vi.fn(async () => "conn-1"),
|
||||
rotateConnection: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { POST } from "../token/route";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import {
|
||||
createConnection,
|
||||
rotateConnection,
|
||||
} from "@/lib/mcp/oauth/connections";
|
||||
import { signArtifact } from "@/lib/mcp/oauth/artifacts";
|
||||
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
|
||||
|
||||
const CLIENT_ID = "https://app.example/client.json";
|
||||
const REDIRECT = "https://claude.ai/api/mcp/auth_callback";
|
||||
const VERIFIER = "a".repeat(64);
|
||||
const CHALLENGE = s256Challenge(VERIFIER);
|
||||
const RESOURCE = "https://health.example/mcp";
|
||||
|
||||
function authCode(overrides: Record<string, unknown> = {}): string {
|
||||
return signArtifact(
|
||||
"authCode",
|
||||
{
|
||||
jti: randomUUID(),
|
||||
sub: "user-1",
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT,
|
||||
code_challenge: CHALLENGE,
|
||||
scope: "health:read offline_access",
|
||||
resource: RESOURCE,
|
||||
...overrides,
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
}
|
||||
|
||||
function tokenReq(fields: Record<string, string>): Request {
|
||||
return new Request("https://health.example/api/mcp/oauth/token", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(createConnection).mockResolvedValue("conn-1");
|
||||
vi.mocked(rotateConnection).mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
function refreshToken(overrides: Record<string, unknown> = {}): string {
|
||||
return signArtifact(
|
||||
"refreshToken",
|
||||
{
|
||||
jti: randomUUID(),
|
||||
cid: "conn-1",
|
||||
sub: "user-1",
|
||||
client_id: CLIENT_ID,
|
||||
scope: "health:read offline_access",
|
||||
resource: RESOURCE,
|
||||
...overrides,
|
||||
},
|
||||
1_000_000,
|
||||
);
|
||||
}
|
||||
|
||||
describe("authorization_code grant", () => {
|
||||
it("exchanges a valid code + verifier for an access token (+ refresh)", async () => {
|
||||
const res = await POST(
|
||||
tokenReq({
|
||||
grant_type: "authorization_code",
|
||||
code: authCode(),
|
||||
code_verifier: VERIFIER,
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT,
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.token_type).toBe("Bearer");
|
||||
expect(body.access_token).toMatch(/^hlk_/);
|
||||
expect(body.refresh_token).toMatch(/^hlrt_/);
|
||||
expect(body.scope).toContain("health:read");
|
||||
expect(issueApiToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ permissions: ["health:read"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a revocable connection anchor and links the access token (H2)", async () => {
|
||||
await POST(
|
||||
tokenReq({
|
||||
grant_type: "authorization_code",
|
||||
code: authCode(),
|
||||
code_verifier: VERIFIER,
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT,
|
||||
}) as never,
|
||||
);
|
||||
expect(createConnection).toHaveBeenCalledTimes(1);
|
||||
expect(issueApiToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mcpConnectionId: "conn-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a wrong PKCE verifier with invalid_grant", async () => {
|
||||
const res = await POST(
|
||||
tokenReq({
|
||||
grant_type: "authorization_code",
|
||||
code: authCode(),
|
||||
code_verifier: "b".repeat(64),
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT,
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toBe("invalid_grant");
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a code bound to a different audience", async () => {
|
||||
const res = await POST(
|
||||
tokenReq({
|
||||
grant_type: "authorization_code",
|
||||
code: authCode({ resource: "https://evil.example/mcp" }),
|
||||
code_verifier: VERIFIER,
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT,
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toBe("invalid_target");
|
||||
});
|
||||
|
||||
it("rejects a code presented by a different client", async () => {
|
||||
const res = await POST(
|
||||
tokenReq({
|
||||
grant_type: "authorization_code",
|
||||
code: authCode(),
|
||||
code_verifier: VERIFIER,
|
||||
client_id: "https://other.example/client.json",
|
||||
redirect_uri: REDIRECT,
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toBe("invalid_grant");
|
||||
});
|
||||
|
||||
it("rejects a replayed code (single-use jti already claimed)", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(
|
||||
tokenReq({
|
||||
grant_type: "authorization_code",
|
||||
code: authCode(),
|
||||
code_verifier: VERIFIER,
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT,
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toBe("invalid_grant");
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh_token grant", () => {
|
||||
it("rotates: issues a new access + refresh pair against a live connection", async () => {
|
||||
const res = await POST(
|
||||
tokenReq({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken(),
|
||||
client_id: CLIENT_ID,
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.access_token).toMatch(/^hlk_/);
|
||||
expect(body.refresh_token).toMatch(/^hlrt_/);
|
||||
expect(rotateConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: "conn-1", clientId: CLIENT_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a refresh whose connection was revoked (H2 — settings revoke)", async () => {
|
||||
vi.mocked(rotateConnection).mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "revoked",
|
||||
});
|
||||
const res = await POST(
|
||||
tokenReq({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken(),
|
||||
client_id: CLIENT_ID,
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toBe("invalid_grant");
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a replayed refresh and reports reuse detection (H2)", async () => {
|
||||
vi.mocked(rotateConnection).mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "reuse_detected",
|
||||
});
|
||||
const res = await POST(
|
||||
tokenReq({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken(),
|
||||
client_id: CLIENT_ID,
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toBe("invalid_grant");
|
||||
expect(body.error_description).toMatch(/reuse/i);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a legacy refresh token with no connection binding", async () => {
|
||||
const legacy = signArtifact(
|
||||
"refreshToken",
|
||||
{
|
||||
jti: randomUUID(),
|
||||
sub: "user-1",
|
||||
client_id: CLIENT_ID,
|
||||
scope: "health:read offline_access",
|
||||
resource: RESOURCE,
|
||||
},
|
||||
1_000_000,
|
||||
);
|
||||
const res = await POST(
|
||||
tokenReq({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: legacy,
|
||||
client_id: CLIENT_ID,
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toBe("invalid_grant");
|
||||
expect(rotateConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("operator kill-switch + origin config (M1/M4)", () => {
|
||||
it("refuses to mint when the API is globally disabled", async () => {
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
|
||||
const res = await POST(
|
||||
tokenReq({
|
||||
grant_type: "authorization_code",
|
||||
code: authCode(),
|
||||
code_verifier: VERIFIER,
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT,
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
expect((await res.json()).error).toBe("temporarily_unavailable");
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("grant validation", () => {
|
||||
it("rejects an unsupported grant_type", async () => {
|
||||
const res = await POST(tokenReq({ grant_type: "password" }) as never);
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toBe("unsupported_grant_type");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Authorization endpoint (OAuth 2.1 authorization-code flow + PKCE S256).
|
||||
*
|
||||
* This is the consent gate of the bridge AS. It is backed by HealthLog's
|
||||
* existing Postgres session (passkey / password login) — there is NO parallel
|
||||
* user store (ADR-006). The flow:
|
||||
*
|
||||
* GET — validate the request; if the caller has no session, prompt them to
|
||||
* sign in (returning here); if they do, render a per-client CONSENT
|
||||
* screen naming the client + the exact scope. NO code is ever issued on
|
||||
* a GET (confused-deputy mitigation: a third party cannot silently
|
||||
* obtain a grant — an authenticated human must click "Allow").
|
||||
* POST — the consent decision. Requires a session, re-validates every
|
||||
* parameter, and on "Allow" mints a single-use, short-lived,
|
||||
* self-describing authorization code bound to this user + client +
|
||||
* redirect URI + PKCE challenge + scope + audience, then 302s back.
|
||||
*
|
||||
* Mandatory gates, every request: PKCE `S256` (a missing / non-S256 challenge is
|
||||
* rejected), audience binding (`resource` MUST equal the canonical `/mcp` URI —
|
||||
* RFC 8707), and an exact redirect-URI match against the resolved client (with
|
||||
* port-agnostic loopback tolerance). Errors are returned directly (never
|
||||
* redirected to an unvalidated URI) so the endpoint is not an open redirector.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import {
|
||||
audienceMatches,
|
||||
AUTH_CODE_TTL_SECONDS,
|
||||
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";
|
||||
import { signArtifact } from "@/lib/mcp/oauth/artifacts";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const AUTHORIZE_LIMIT = 60;
|
||||
const AUTHORIZE_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
const baseParams = {
|
||||
response_type: z.string(),
|
||||
client_id: z.string().min(1).max(4096),
|
||||
redirect_uri: z.string().min(1).max(2048),
|
||||
code_challenge: z.string().min(1).max(256).optional(),
|
||||
code_challenge_method: z.string().optional(),
|
||||
scope: z.string().max(256).optional(),
|
||||
state: z.string().max(2048).optional(),
|
||||
resource: z.string().max(2048).optional(),
|
||||
};
|
||||
|
||||
function htmlEscape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function badRequest(error: string, description: string): Response {
|
||||
return Response.json(
|
||||
{ error, error_description: description },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
function html(body: string, status = 200): Response {
|
||||
return new Response(
|
||||
`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>HealthLog — Authorize access</title></head><body>${body}</body></html>`,
|
||||
{
|
||||
status,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(" ");
|
||||
}
|
||||
|
||||
interface ValidatedRequest {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
/** The VERIFIED client origin shown on the consent screen (L1). */
|
||||
clientOrigin: string;
|
||||
/** Registration provenance — drives the trust label on consent (L1). */
|
||||
clientSource: "cimd" | "dcr";
|
||||
redirectUri: string;
|
||||
codeChallenge: string;
|
||||
scope: string;
|
||||
state?: string;
|
||||
resource: string;
|
||||
}
|
||||
|
||||
/** Shared validation for GET + POST. Returns a ready error Response on failure. */
|
||||
async function validate(
|
||||
params: Record<string, string | undefined>,
|
||||
requestUrl: string,
|
||||
): Promise<{ ok: true; v: ValidatedRequest } | { ok: false; res: Response }> {
|
||||
const parsed = z.object(baseParams).safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
res: badRequest("invalid_request", "Missing or malformed parameters"),
|
||||
};
|
||||
}
|
||||
const p = parsed.data;
|
||||
|
||||
// 1. Client must resolve (CIMD URL or stateless DCR id). Unknown → cannot
|
||||
// safely redirect anywhere, so error directly.
|
||||
const resolved = await resolveClient(p.client_id);
|
||||
if (!resolved.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
res: badRequest("invalid_client", "Unknown or unresolvable client"),
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Redirect URI MUST match the registration before we trust it.
|
||||
if (!redirectUriAllowed(p.redirect_uri, resolved.client.redirectUris)) {
|
||||
return {
|
||||
ok: false,
|
||||
res: badRequest(
|
||||
"invalid_request",
|
||||
"redirect_uri does not match the registered client",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 3. response_type — code only.
|
||||
if (p.response_type !== "code") {
|
||||
return {
|
||||
ok: false,
|
||||
res: badRequest(
|
||||
"unsupported_response_type",
|
||||
"Only response_type=code is supported",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 4. PKCE S256 is MANDATORY.
|
||||
if (
|
||||
p.code_challenge_method !== "S256" ||
|
||||
!isValidChallenge(p.code_challenge)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
res: badRequest(
|
||||
"invalid_request",
|
||||
"PKCE with code_challenge_method=S256 is required",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Audience binding (RFC 8707) — resource MUST equal the canonical /mcp URI.
|
||||
if (!audienceMatches(p.resource, requestUrl)) {
|
||||
return {
|
||||
ok: false,
|
||||
res: badRequest(
|
||||
"invalid_target",
|
||||
"resource must equal this server's MCP endpoint",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// L1 — the VERIFIED origin. For CIMD the `client_id` IS the document URL, so
|
||||
// its host is cryptographically tied to the fetched-and-matched document; for
|
||||
// DCR there is no verified origin (anyone can self-register), so the consent
|
||||
// screen labels it unverified rather than trusting the self-asserted name.
|
||||
const clientOrigin =
|
||||
resolved.client.source === "cimd"
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(p.client_id).host;
|
||||
} catch {
|
||||
return p.client_id;
|
||||
}
|
||||
})()
|
||||
: "";
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
v: {
|
||||
clientId: p.client_id,
|
||||
clientName: resolved.client.clientName,
|
||||
clientOrigin,
|
||||
clientSource: resolved.client.source,
|
||||
redirectUri: p.redirect_uri,
|
||||
codeChallenge: p.code_challenge as string,
|
||||
scope: grantScope(p.scope),
|
||||
state: p.state,
|
||||
resource: p.resource as string,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* L3 — same-origin assertion for the consent POST. A non-browser caller sends
|
||||
* neither `Origin` nor `Sec-Fetch-Site`, so their absence is allowed; a present
|
||||
* `Origin` that is not our own origin, or a `Sec-Fetch-Site` that is not
|
||||
* `same-origin`, is refused. Mirrors the `/mcp` DNS-rebinding posture.
|
||||
*/
|
||||
function consentOriginAllowed(request: NextRequest): boolean {
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin && origin !== resolveBaseOrigin(request.url)) return false;
|
||||
const site = request.headers.get("sec-fetch-site");
|
||||
if (site && site !== "same-origin") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function redirectBack(
|
||||
redirectUri: string,
|
||||
query: Record<string, string>,
|
||||
): Response {
|
||||
const url = new URL(redirectUri);
|
||||
for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
|
||||
return Response.redirect(url.toString(), 302);
|
||||
}
|
||||
|
||||
/** Whether the surface is available (M1 origin pinned + M4 kill-switch off). */
|
||||
async function surfaceAvailable(): Promise<boolean> {
|
||||
return isMcpOriginConfigured() && (await isApiGloballyEnabled());
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest): Promise<Response> {
|
||||
return withBackgroundEvent("mcp.oauth.authorize", async () => {
|
||||
if (!(await surfaceAvailable())) {
|
||||
return Response.json(
|
||||
{ error: "temporarily_unavailable" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"mcp:oauth:authorize",
|
||||
AUTHORIZE_LIMIT,
|
||||
AUTHORIZE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return Response.json(
|
||||
{ error: "temporarily_unavailable" },
|
||||
{
|
||||
status: 429,
|
||||
headers: rateLimitHeaders({
|
||||
allowed: false,
|
||||
remaining: rl.remaining,
|
||||
resetAt: rl.resetAt,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const params = Object.fromEntries(url.searchParams.entries());
|
||||
const result = await validate(params, request.url);
|
||||
if (!result.ok) {
|
||||
annotate({ action: { name: "mcp.oauth.authorize.invalid" } });
|
||||
return result.res;
|
||||
}
|
||||
const { v } = result;
|
||||
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
// No identity yet — send the human to sign in, returning to this exact
|
||||
// request. NO grant is issued without an authenticated decision.
|
||||
const next = url.pathname + url.search;
|
||||
const loginHref = `/auth/login?next=${encodeURIComponent(next)}`;
|
||||
annotate({ action: { name: "mcp.oauth.authorize.login_required" } });
|
||||
return html(
|
||||
`<main><h1>Sign in to HealthLog</h1><p>To connect <strong>${htmlEscape(v.clientName)}</strong> you must sign in first.</p><p><a href="${htmlEscape(loginHref)}">Sign in and continue</a></p></main>`,
|
||||
);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "mcp.oauth.authorize.consent_shown" } });
|
||||
// Consent screen — a plain POST form (no inline script/style, CSP-safe).
|
||||
const hidden = (name: string, value: string) =>
|
||||
`<input type="hidden" name="${name}" value="${htmlEscape(value)}">`;
|
||||
// L1 — show the VERIFIED origin (or an explicit "unverified" label for DCR)
|
||||
// next to the self-asserted name so the user authorizes who, not just what.
|
||||
const provenance =
|
||||
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></p>
|
||||
${accessSummary}
|
||||
<form method="POST" action="/api/mcp/oauth/authorize">
|
||||
${hidden("response_type", "code")}
|
||||
${hidden("client_id", v.clientId)}
|
||||
${hidden("redirect_uri", v.redirectUri)}
|
||||
${hidden("code_challenge", v.codeChallenge)}
|
||||
${hidden("code_challenge_method", "S256")}
|
||||
${hidden("scope", v.scope)}
|
||||
${hidden("resource", v.resource)}
|
||||
${v.state !== undefined ? hidden("state", v.state) : ""}
|
||||
<button type="submit" name="decision" value="allow">Allow</button>
|
||||
<button type="submit" name="decision" value="deny">Deny</button>
|
||||
</form>
|
||||
</main>`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
return withBackgroundEvent("mcp.oauth.authorize", async () => {
|
||||
if (!(await surfaceAvailable())) {
|
||||
return Response.json(
|
||||
{ error: "temporarily_unavailable" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
// L3 — defence-in-depth same-origin assertion on the consent decision. The
|
||||
// session cookie is already SameSite=Lax (a cross-site POST carries none),
|
||||
// but reject an explicit cross-origin Origin / Sec-Fetch-Site outright so a
|
||||
// forged "allow" cannot ride a present session.
|
||||
if (!consentOriginAllowed(request)) {
|
||||
annotate({ action: { name: "mcp.oauth.authorize.cross_origin" } });
|
||||
return Response.json(
|
||||
{ error: "access_denied", error_description: "Cross-origin POST" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"mcp:oauth:authorize",
|
||||
AUTHORIZE_LIMIT,
|
||||
AUTHORIZE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return Response.json(
|
||||
{ error: "temporarily_unavailable" },
|
||||
{
|
||||
status: 429,
|
||||
headers: rateLimitHeaders({
|
||||
allowed: false,
|
||||
remaining: rl.remaining,
|
||||
resetAt: rl.resetAt,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// The consent decision MUST come from an authenticated human.
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
annotate({
|
||||
action: { name: "mcp.oauth.authorize.decision_unauthenticated" },
|
||||
});
|
||||
return Response.json(
|
||||
{ error: "access_denied", error_description: "Sign in required" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const params: Record<string, string | undefined> = {};
|
||||
for (const key of [
|
||||
"response_type",
|
||||
"client_id",
|
||||
"redirect_uri",
|
||||
"code_challenge",
|
||||
"code_challenge_method",
|
||||
"scope",
|
||||
"state",
|
||||
"resource",
|
||||
"decision",
|
||||
]) {
|
||||
const value = form.get(key);
|
||||
params[key] = typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
const result = await validate(params, request.url);
|
||||
if (!result.ok) {
|
||||
annotate({ action: { name: "mcp.oauth.authorize.invalid" } });
|
||||
return result.res;
|
||||
}
|
||||
const { v } = result;
|
||||
|
||||
if (params.decision !== "allow") {
|
||||
annotate({ action: { name: "mcp.oauth.authorize.denied" } });
|
||||
return redirectBack(v.redirectUri, {
|
||||
error: "access_denied",
|
||||
...(v.state !== undefined ? { state: v.state } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const code = signArtifact(
|
||||
"authCode",
|
||||
{
|
||||
// `jti` makes the code single-use: the token endpoint claims it once
|
||||
// through the atomic rate-limit upsert, so a replay within the TTL is
|
||||
// rejected.
|
||||
jti: randomUUID(),
|
||||
sub: session.user.id,
|
||||
client_id: v.clientId,
|
||||
// Carried so the token endpoint can label the persistent connection
|
||||
// (H2) without re-resolving the client.
|
||||
client_name: v.clientName,
|
||||
redirect_uri: v.redirectUri,
|
||||
code_challenge: v.codeChallenge,
|
||||
scope: v.scope,
|
||||
resource: v.resource,
|
||||
},
|
||||
AUTH_CODE_TTL_SECONDS * 1000,
|
||||
);
|
||||
|
||||
await auditLog("mcp.oauth.authorize.granted", {
|
||||
userId: session.user.id,
|
||||
details: { client_id: v.clientId, scope: v.scope },
|
||||
});
|
||||
annotate({ action: { name: "mcp.oauth.authorize.granted" } });
|
||||
|
||||
return redirectBack(v.redirectUri, {
|
||||
code,
|
||||
...(v.state !== undefined ? { state: v.state } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Dynamic Client Registration (RFC 7591) — the DCR fallback for clients that
|
||||
* do not present a CIMD `client_id` URL.
|
||||
*
|
||||
* Registration is STATELESS: the issued `client_id` is a signed, self-describing
|
||||
* `hlc_` artifact carrying the registered `redirect_uris` + name (see
|
||||
* `clients.ts` / `artifacts.ts`), so no client row is written. The endpoint only
|
||||
* validates the metadata, enforces a per-IP rate limit (it is unauthenticated),
|
||||
* and returns the RFC 7591 client-information response for a PUBLIC client
|
||||
* (`token_endpoint_auth_method: "none"` — PKCE is the proof, there is no secret).
|
||||
*
|
||||
* Redirect URIs are shape-validated here; the AUTHORITATIVE redirect-URI match
|
||||
* happens at `/authorize` against the decoded registration.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { readBodyCapped } from "@/lib/http/read-capped";
|
||||
import {
|
||||
isAllowableRedirectUri,
|
||||
registerDcrClient,
|
||||
} from "@/lib/mcp/oauth/clients";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const REGISTER_LIMIT = 20;
|
||||
const REGISTER_WINDOW_MS = 60 * 60 * 1000;
|
||||
const MAX_BODY_BYTES = 16 * 1024;
|
||||
|
||||
const registrationSchema = z.object({
|
||||
redirect_uris: z
|
||||
.array(z.string().min(1).max(2048))
|
||||
.min(1)
|
||||
.max(12)
|
||||
.refine((uris) => uris.every(isAllowableRedirectUri), {
|
||||
message: "redirect_uris must be HTTPS or http loopback URLs",
|
||||
}),
|
||||
client_name: z.string().min(1).max(200).optional(),
|
||||
// Accepted-and-ignored RFC 7591 fields (we never read them but tolerate them).
|
||||
grant_types: z.array(z.string()).optional(),
|
||||
response_types: z.array(z.string()).optional(),
|
||||
token_endpoint_auth_method: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
});
|
||||
|
||||
function oauthError(
|
||||
error: string,
|
||||
description: string,
|
||||
status: number,
|
||||
extraHeaders: Record<string, string> = {},
|
||||
): Response {
|
||||
return Response.json(
|
||||
{ error, error_description: description },
|
||||
{ status, headers: extraHeaders },
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
return withBackgroundEvent("mcp.oauth.register", async () => {
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"mcp:oauth:register",
|
||||
REGISTER_LIMIT,
|
||||
REGISTER_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({ action: { name: "mcp.oauth.register.rate_limited" } });
|
||||
return Response.json(
|
||||
{ error: "temporarily_unavailable" },
|
||||
{
|
||||
status: 429,
|
||||
headers: rateLimitHeaders({
|
||||
allowed: false,
|
||||
remaining: rl.remaining,
|
||||
resetAt: rl.resetAt,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// M4 — the operator API kill-switch covers the OAuth surface too.
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return oauthError("temporarily_unavailable", "API is disabled", 503);
|
||||
}
|
||||
|
||||
// M3 — enforce the cap WHILE reading the (unauthenticated) registration
|
||||
// body so a hostile client cannot buffer an oversized payload first.
|
||||
const read = await readBodyCapped(request, MAX_BODY_BYTES);
|
||||
if (!read.ok) {
|
||||
return oauthError("invalid_client_metadata", "Payload too large", 400);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(read.text);
|
||||
} catch {
|
||||
return oauthError("invalid_client_metadata", "Invalid JSON", 400);
|
||||
}
|
||||
|
||||
const parsed = registrationSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({ action: { name: "mcp.oauth.register.invalid" } });
|
||||
return oauthError(
|
||||
"invalid_client_metadata",
|
||||
"redirect_uris is required and must contain HTTPS or loopback URLs",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const client = registerDcrClient({
|
||||
clientName: parsed.data.client_name ?? "MCP client",
|
||||
redirectUris: parsed.data.redirect_uris,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "mcp.oauth.register.issued" },
|
||||
meta: { source: "dcr", redirect_uri_count: client.redirectUris.length },
|
||||
});
|
||||
|
||||
// RFC 7591 client-information response for a public client.
|
||||
return Response.json(
|
||||
{
|
||||
client_id: client.clientId,
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
redirect_uris: client.redirectUris,
|
||||
client_name: client.clientName,
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* 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 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.
|
||||
*
|
||||
* Gates:
|
||||
* - PKCE — the presented `code_verifier` MUST hash (S256) to the
|
||||
* `code_challenge` bound into the authorization code.
|
||||
* - Single use — the code's `jti` is claimed once through the atomic
|
||||
* rate-limit upsert; a replay within the TTL is rejected.
|
||||
* - Audience — the code's bound `resource` MUST equal the canonical `/mcp`
|
||||
* URI (RFC 8707); a `resource` parameter, when sent, must match too.
|
||||
* - Client binding — the `client_id` presented at `/token` MUST equal the one
|
||||
* the code was issued to.
|
||||
*
|
||||
* Refresh tokens rotate on every use (OAuth 2.1 public-client rotation): a fresh
|
||||
* access + refresh pair is returned and the presented refresh `jti` is claimed
|
||||
* so it cannot be reused.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkAuthSurfaceRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import {
|
||||
ACCESS_TOKEN_TTL_MINUTES,
|
||||
audienceMatches,
|
||||
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";
|
||||
import {
|
||||
createConnection,
|
||||
rotateConnection,
|
||||
} from "@/lib/mcp/oauth/connections";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TOKEN_LIMIT = 120;
|
||||
const TOKEN_WINDOW_MS = 15 * 60 * 1000;
|
||||
|
||||
function oauthError(
|
||||
error: string,
|
||||
description: string,
|
||||
status = 400,
|
||||
): Response {
|
||||
return Response.json(
|
||||
{ error, error_description: description },
|
||||
{ status, headers: { "Cache-Control": "no-store", Pragma: "no-cache" } },
|
||||
);
|
||||
}
|
||||
|
||||
function tokenSuccess(body: Record<string, unknown>): Response {
|
||||
return Response.json(body, {
|
||||
headers: { "Cache-Control": "no-store", Pragma: "no-cache" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Claim a one-time-use jti through the atomic limiter (limit 1 ⇒ single use). */
|
||||
async function claimJti(
|
||||
kind: string,
|
||||
jti: string,
|
||||
ttlMs: number,
|
||||
): Promise<boolean> {
|
||||
if (typeof jti !== "string" || jti.length === 0) return false;
|
||||
const rl = await checkRateLimit(`oauth:jti:${kind}:${jti}`, 1, ttlMs);
|
||||
return rl.allowed;
|
||||
}
|
||||
|
||||
interface AuthCodeClaims {
|
||||
jti: string;
|
||||
sub: string;
|
||||
client_id: string;
|
||||
client_name?: string;
|
||||
redirect_uri: string;
|
||||
code_challenge: string;
|
||||
scope: string;
|
||||
resource: string;
|
||||
}
|
||||
|
||||
interface RefreshClaims {
|
||||
jti: string;
|
||||
/** Connection id — the persistent, revocable anchor for the chain (H2). */
|
||||
cid?: string;
|
||||
sub: string;
|
||||
client_id: string;
|
||||
scope: string;
|
||||
resource: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint the access token (+ refresh artifact when `offline_access` is granted).
|
||||
*
|
||||
* `connectionId` is the persistent anchor for the refresh chain (H2): the access
|
||||
* token is linked to it (so a connection revoke kills it) and the refresh
|
||||
* artifact carries it (`cid`). `refreshJti` is the connection's new
|
||||
* `currentJti` — for the `authorization_code` grant the caller has just created
|
||||
* the connection seeded with it; for `refresh_token` the caller has just
|
||||
* advanced the connection to it.
|
||||
*/
|
||||
async function mintTokenPair(args: {
|
||||
userId: string;
|
||||
clientId: string;
|
||||
scope: string;
|
||||
resource: string;
|
||||
grant: "authorization_code" | "refresh_token";
|
||||
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,
|
||||
expiresInMinutes: ACCESS_TOKEN_TTL_MINUTES,
|
||||
...(args.connectionId ? { mcpConnectionId: args.connectionId } : {}),
|
||||
});
|
||||
|
||||
const refresh =
|
||||
args.connectionId && args.refreshJti
|
||||
? signArtifact(
|
||||
"refreshToken",
|
||||
{
|
||||
jti: args.refreshJti,
|
||||
cid: args.connectionId,
|
||||
sub: args.userId,
|
||||
client_id: args.clientId,
|
||||
scope: args.scope,
|
||||
resource: args.resource,
|
||||
},
|
||||
REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
await auditLog("mcp.oauth.token.issued", {
|
||||
userId: args.userId,
|
||||
details: { client_id: args.clientId, grant: args.grant, scope: args.scope },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "mcp.oauth.token.issued" },
|
||||
meta: { grant: args.grant },
|
||||
});
|
||||
|
||||
return tokenSuccess({
|
||||
access_token: access.token,
|
||||
token_type: "Bearer",
|
||||
expires_in: ACCESS_TOKEN_TTL_MINUTES * 60,
|
||||
scope: args.scope,
|
||||
...(refresh ? { refresh_token: refresh } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<Response> {
|
||||
return withBackgroundEvent("mcp.oauth.token", async () => {
|
||||
// M1 — fail closed without a pinned origin; M4 — honour the operator's
|
||||
// global API kill-switch. Either off and the bridge mints no tokens.
|
||||
if (!isMcpOriginConfigured() || !(await isApiGloballyEnabled())) {
|
||||
return oauthError(
|
||||
"temporarily_unavailable",
|
||||
"The MCP OAuth surface is unavailable",
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"mcp:oauth:token",
|
||||
TOKEN_LIMIT,
|
||||
TOKEN_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return Response.json(
|
||||
{ error: "temporarily_unavailable" },
|
||||
{
|
||||
status: 429,
|
||||
headers: rateLimitHeaders({
|
||||
allowed: false,
|
||||
remaining: rl.remaining,
|
||||
resetAt: rl.resetAt,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return oauthError(
|
||||
"invalid_request",
|
||||
"Expected application/x-www-form-urlencoded body",
|
||||
);
|
||||
}
|
||||
const get = (k: string): string | undefined => {
|
||||
const v = form.get(k);
|
||||
return typeof v === "string" ? v : undefined;
|
||||
};
|
||||
|
||||
const grantType = get("grant_type");
|
||||
|
||||
if (grantType === "authorization_code") {
|
||||
const code = get("code");
|
||||
const verifier = get("code_verifier");
|
||||
const clientId = get("client_id");
|
||||
const redirectUri = get("redirect_uri");
|
||||
|
||||
if (!code || !verifier || !clientId || !redirectUri) {
|
||||
return oauthError(
|
||||
"invalid_request",
|
||||
"Missing code, code_verifier, client_id, or redirect_uri",
|
||||
);
|
||||
}
|
||||
|
||||
const verified = verifyArtifact<AuthCodeClaims>("authCode", code);
|
||||
if (!verified.ok) {
|
||||
annotate({ action: { name: "mcp.oauth.token.code_rejected" } });
|
||||
return oauthError(
|
||||
"invalid_grant",
|
||||
"Authorization code is invalid or expired",
|
||||
);
|
||||
}
|
||||
const claims = verified.claims;
|
||||
|
||||
// Client + redirect binding.
|
||||
if (
|
||||
claims.client_id !== clientId ||
|
||||
claims.redirect_uri !== redirectUri
|
||||
) {
|
||||
return oauthError(
|
||||
"invalid_grant",
|
||||
"Authorization code was issued to a different client/redirect",
|
||||
);
|
||||
}
|
||||
// Audience: a resource parameter, if present, must also match.
|
||||
const resourceParam = get("resource");
|
||||
if (resourceParam && !audienceMatches(resourceParam, request.url)) {
|
||||
return oauthError(
|
||||
"invalid_target",
|
||||
"resource does not match the bound audience",
|
||||
);
|
||||
}
|
||||
if (!audienceMatches(claims.resource, request.url)) {
|
||||
return oauthError(
|
||||
"invalid_target",
|
||||
"Authorization code is bound to a different audience",
|
||||
);
|
||||
}
|
||||
// PKCE.
|
||||
if (!verifyPkceS256(verifier, claims.code_challenge)) {
|
||||
annotate({ action: { name: "mcp.oauth.token.pkce_failed" } });
|
||||
return oauthError("invalid_grant", "PKCE verification failed");
|
||||
}
|
||||
// Single use — claim the code's jti.
|
||||
if (!(await claimJti("code", claims.jti, 5 * 60 * 1000))) {
|
||||
annotate({ action: { name: "mcp.oauth.token.code_replayed" } });
|
||||
return oauthError(
|
||||
"invalid_grant",
|
||||
"Authorization code has already been used",
|
||||
);
|
||||
}
|
||||
|
||||
// H2 — when offline_access is granted, create the persistent, revocable
|
||||
// connection anchor seeded with the first refresh jti. Without it the
|
||||
// refresh chain would be stateless and un-revocable.
|
||||
const offlineGranted = claims.scope
|
||||
.split(/\s+/)
|
||||
.includes("offline_access");
|
||||
let connectionId: string | undefined;
|
||||
let refreshJti: string | undefined;
|
||||
if (offlineGranted) {
|
||||
refreshJti = randomUUID();
|
||||
connectionId = await createConnection({
|
||||
userId: claims.sub,
|
||||
clientId: claims.client_id,
|
||||
clientName: claims.client_name ?? "MCP client",
|
||||
scope: claims.scope,
|
||||
resource: claims.resource,
|
||||
jti: refreshJti,
|
||||
});
|
||||
}
|
||||
|
||||
return mintTokenPair({
|
||||
userId: claims.sub,
|
||||
clientId: claims.client_id,
|
||||
scope: claims.scope,
|
||||
resource: claims.resource,
|
||||
grant: "authorization_code",
|
||||
connectionId,
|
||||
refreshJti,
|
||||
});
|
||||
}
|
||||
|
||||
if (grantType === "refresh_token") {
|
||||
const refreshToken = get("refresh_token");
|
||||
const clientId = get("client_id");
|
||||
if (!refreshToken || !clientId) {
|
||||
return oauthError(
|
||||
"invalid_request",
|
||||
"Missing refresh_token or client_id",
|
||||
);
|
||||
}
|
||||
|
||||
const verified = verifyArtifact<RefreshClaims>(
|
||||
"refreshToken",
|
||||
refreshToken,
|
||||
);
|
||||
if (!verified.ok) {
|
||||
return oauthError(
|
||||
"invalid_grant",
|
||||
"Refresh token is invalid or expired",
|
||||
);
|
||||
}
|
||||
const claims = verified.claims;
|
||||
if (claims.client_id !== clientId) {
|
||||
return oauthError(
|
||||
"invalid_grant",
|
||||
"Refresh token was issued to a different client",
|
||||
);
|
||||
}
|
||||
if (!audienceMatches(claims.resource, request.url)) {
|
||||
return oauthError(
|
||||
"invalid_target",
|
||||
"Refresh token is bound to a different audience",
|
||||
);
|
||||
}
|
||||
|
||||
// H2 — rotation against the persistent connection anchor. The connection's
|
||||
// `currentJti` is the single valid refresh jti; presenting any other (an
|
||||
// already-rotated artifact) is a replay that revokes the whole connection
|
||||
// (reuse-detection family revocation). A revoked connection always fails,
|
||||
// so the settings "revoke" terminates the chain.
|
||||
if (!claims.cid) {
|
||||
return oauthError(
|
||||
"invalid_grant",
|
||||
"Refresh token is not bound to a connection",
|
||||
);
|
||||
}
|
||||
const newJti = randomUUID();
|
||||
const rotation = await rotateConnection({
|
||||
connectionId: claims.cid,
|
||||
presentedJti: claims.jti,
|
||||
newJti,
|
||||
clientId: claims.client_id,
|
||||
userId: claims.sub,
|
||||
});
|
||||
if (!rotation.ok) {
|
||||
annotate({
|
||||
action: {
|
||||
name:
|
||||
rotation.reason === "reuse_detected"
|
||||
? "mcp.oauth.token.refresh_reuse_detected"
|
||||
: "mcp.oauth.token.refresh_rejected",
|
||||
},
|
||||
meta: { reason: rotation.reason },
|
||||
});
|
||||
return oauthError(
|
||||
"invalid_grant",
|
||||
rotation.reason === "reuse_detected"
|
||||
? "Refresh token reuse detected; the connection was revoked"
|
||||
: "Refresh token is invalid, revoked, or already used",
|
||||
);
|
||||
}
|
||||
|
||||
return mintTokenPair({
|
||||
userId: claims.sub,
|
||||
clientId: claims.client_id,
|
||||
scope: claims.scope,
|
||||
resource: claims.resource,
|
||||
grant: "refresh_token",
|
||||
connectionId: claims.cid,
|
||||
refreshJti: newJti,
|
||||
});
|
||||
}
|
||||
|
||||
return oauthError(
|
||||
"unsupported_grant_type",
|
||||
"Only authorization_code and refresh_token are supported",
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Revoke an MCP connector token. Only the owning user can revoke; the scope
|
||||
* filter keeps this endpoint to `health:read` tokens so it can never revoke a
|
||||
* differently-scoped credential by id alone.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SCOPE_HEALTH_READ } from "@/lib/mcp/oauth/config";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: Request, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "mcp.tokens.revoke" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const token = await prisma.apiToken.findUnique({ where: { id } });
|
||||
|
||||
if (
|
||||
!token ||
|
||||
token.userId !== user.id ||
|
||||
!token.permissions.includes(SCOPE_HEALTH_READ)
|
||||
) {
|
||||
return apiError("Token not found", 404);
|
||||
}
|
||||
|
||||
await prisma.apiToken.update({ where: { id }, data: { revoked: true } });
|
||||
await auditLog("mcp.tokens.revoke", {
|
||||
userId: user.id,
|
||||
details: { tokenId: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ revoked: true });
|
||||
},
|
||||
);
|
||||
@@ -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("*");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* MCP connector tokens — list + mint.
|
||||
*
|
||||
* The MCP settings card mints a DEDICATED `health:read`-scoped Bearer here (NOT
|
||||
* the `medication:ingest` scope the generic `/api/tokens` mints, and NEVER the
|
||||
* `["*"]` wildcard). The raw `hlk_` value is shown once. This is the manual /
|
||||
* stdio / power-user path; the OAuth bridge (`/api/mcp/oauth/*`) mints the same
|
||||
* `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. 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";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
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, 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 () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "mcp.tokens.list" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
// M2 — list only manually-minted connector tokens. OAuth access rows
|
||||
// (`mcpConnectionId != null`) are transient 60-minute credentials that would
|
||||
// flood this list; they are surfaced (and revoked) as connections instead.
|
||||
const tokens = await prisma.apiToken.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
permissions: { has: SCOPE_HEALTH_READ },
|
||||
mcpConnectionId: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
permissions: true,
|
||||
lastUsedAt: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
revoked: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return apiSuccess(tokens);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "mcp.tokens.create" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
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,
|
||||
permissions,
|
||||
expiresInDays: parsed.data.expiresInDays ?? 90,
|
||||
});
|
||||
|
||||
await auditLog("mcp.tokens.create", {
|
||||
userId: user.id,
|
||||
details: { tokenId: issued.tokenId, scope: permissions.join(" ") },
|
||||
});
|
||||
|
||||
// Return the raw token ONCE — it can never be retrieved again.
|
||||
return apiSuccess(
|
||||
{ token: issued.token, name: issued.name, expiresAt: issued.expiresAt },
|
||||
201,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Remote MCP endpoint (`/mcp`) — transport + gate tests.
|
||||
*
|
||||
* Drives the route handler with Web-standard `Request`s and asserts the
|
||||
* four front gates plus the read-only / admin-unreachable posture over the
|
||||
* HTTP wire:
|
||||
*
|
||||
* - serves the tool list with a valid token (module on, under budget);
|
||||
* - rejects a missing / malformed / rejected (revoked / expired / unknown)
|
||||
* token with one blunt 401;
|
||||
* - hides the surface (404) when the off-by-default `mcp` module is off;
|
||||
* - trips 429 when the per-credential rate limit is exhausted;
|
||||
* - never consults a cookie session, so the cookie-only `requireAdmin()`
|
||||
* boundary is unreachable here regardless of token scope — a `["*"]`
|
||||
* wildcard token still sees only the read tools (ADR-003 / ADR-005).
|
||||
*
|
||||
* The Bearer validator, module gate, and rate limiter are mocked so the
|
||||
* test exercises the route's own wiring without a DB; the structural
|
||||
* cookie-only admin boundary itself is pinned by the sibling
|
||||
* `src/lib/mcp/__tests__/admin-boundary.test.ts`.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// The surface fails closed without a pinned origin (M1); pin it for the suite.
|
||||
process.env.APP_URL = "http://localhost";
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
isApiGloballyEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("@/lib/auth/bearer", () => ({
|
||||
resolveBearerToken: vi.fn(),
|
||||
BearerAuthError: class BearerAuthError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkMcpRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn((r: { remaining: number; resetAt: number }) => ({
|
||||
"X-RateLimit-Remaining": String(r.remaining),
|
||||
"X-RateLimit-Reset": new Date(r.resetAt).toISOString(),
|
||||
})),
|
||||
}));
|
||||
// The factory pulls the Coach retrieval layer through `tools.ts`; stub the
|
||||
// heavy executor + inventory so importing the route never reaches a DB. The
|
||||
// list/reject paths never execute a tool, so the stubs are never called.
|
||||
vi.mock("@/lib/ai/coach/tools/executor", () => ({ executeCoachTool: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/tools/inventory", () => ({
|
||||
buildCoachDataInventory: vi.fn(),
|
||||
}));
|
||||
// A cookie path must NEVER be reached from the MCP wire. Mock both cookie
|
||||
// sources so the test can assert they are never consulted.
|
||||
const getSession = vi.fn();
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession }));
|
||||
const cookiesGet = vi.fn(() => undefined);
|
||||
const headersGet = vi.fn(() => null);
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: vi.fn(async () => ({
|
||||
get: cookiesGet,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
headers: vi.fn(async () => ({ get: headersGet })),
|
||||
}));
|
||||
|
||||
import { POST, GET } from "../route";
|
||||
import { resolveBearerToken } from "@/lib/auth/bearer";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkMcpRateLimit } from "@/lib/rate-limit";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
|
||||
const READ_TOOLS = [
|
||||
"get_correlations",
|
||||
"get_labs",
|
||||
"get_medication_compliance",
|
||||
"get_metric_series",
|
||||
"list_metrics",
|
||||
"search",
|
||||
"fetch",
|
||||
// Phase 4 — deep-value reads.
|
||||
"get_correlation",
|
||||
"compare_metric",
|
||||
"get_metric_baseline",
|
||||
"detect_changepoints",
|
||||
].sort();
|
||||
|
||||
/** A valid, narrow-scope (`health:read`) token resolution. */
|
||||
function validToken(permissions: string[] = ["health:read"]) {
|
||||
vi.mocked(resolveBearerToken).mockResolvedValue({
|
||||
user: { id: "user-1" } as never,
|
||||
tokenId: "token-1",
|
||||
permissions,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
});
|
||||
}
|
||||
|
||||
function postRpc(body: unknown, headers: Record<string, string> = {}): Request {
|
||||
return new Request("http://localhost/mcp", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: "Bearer hlk_" + "a".repeat(64),
|
||||
"content-type": "application/json",
|
||||
accept: "application/json, text/event-stream",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const TOOLS_LIST = { jsonrpc: "2.0", id: 1, method: "tools/list" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cookiesGet.mockReturnValue(undefined);
|
||||
headersGet.mockReturnValue(null);
|
||||
// Default happy-path: module on, under budget.
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(true);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(checkMcpRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 119,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
describe("/mcp — happy path", () => {
|
||||
it("serves the read-tool list over HTTP with a valid token", async () => {
|
||||
validToken();
|
||||
const res = await POST(postRpc(TOOLS_LIST));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
const names = (body.result.tools as Array<{ name: string }>)
|
||||
.map((t) => t.name)
|
||||
.sort();
|
||||
expect(names).toEqual(READ_TOOLS);
|
||||
|
||||
// The gate ran in the documented order: auth → rate-limit → module.
|
||||
expect(resolveBearerToken).toHaveBeenCalledTimes(1);
|
||||
expect(checkMcpRateLimit).toHaveBeenCalledWith("user-1:token-1");
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mcp");
|
||||
});
|
||||
|
||||
it("completes the initialize handshake with the HealthLog server identity", async () => {
|
||||
validToken();
|
||||
const res = await POST(
|
||||
postRpc({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2025-06-18",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "test", version: "1" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.result.serverInfo.name).toBe("healthlog");
|
||||
// Tools + resources are advertised; no admin capability exists.
|
||||
expect(body.result.capabilities.tools).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/mcp — Origin gate (DNS-rebinding defense, spec PR #1439)", () => {
|
||||
it("rejects a cross-origin browser request with 403 before auth", async () => {
|
||||
validToken();
|
||||
const res = await POST(
|
||||
postRpc(TOOLS_LIST, { origin: "https://evil.example" }),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
// The Origin check runs first — the token table is never consulted.
|
||||
expect(resolveBearerToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a same-origin request", async () => {
|
||||
validToken();
|
||||
const res = await POST(postRpc(TOOLS_LIST, { origin: "http://localhost" }));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("allows a request with no Origin header (non-browser MCP client)", async () => {
|
||||
validToken();
|
||||
const res = await POST(postRpc(TOOLS_LIST));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/mcp — operator kill-switch (M4)", () => {
|
||||
it("hides the surface (404) when the API is globally disabled", async () => {
|
||||
validToken();
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
|
||||
const res = await POST(postRpc(TOOLS_LIST));
|
||||
expect(res.status).toBe(404);
|
||||
// Fails closed before the token table is consulted.
|
||||
expect(resolveBearerToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/mcp — 401 carries the RFC 9728 resource_metadata pointer", () => {
|
||||
it("includes resource_metadata in the WWW-Authenticate challenge", async () => {
|
||||
const res = await POST(
|
||||
new Request("http://localhost/mcp", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(TOOLS_LIST),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
const challenge = res.headers.get("www-authenticate") ?? "";
|
||||
expect(challenge).toMatch(/Bearer/);
|
||||
expect(challenge).toMatch(
|
||||
/resource_metadata="[^"]*\/\.well-known\/oauth-protected-resource"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/mcp — authentication", () => {
|
||||
it("rejects a request with no Authorization header (401 + challenge)", async () => {
|
||||
const res = await POST(
|
||||
new Request("http://localhost/mcp", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(TOOLS_LIST),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.headers.get("www-authenticate")).toMatch(/Bearer/);
|
||||
// The token table is never consulted when no token is presented.
|
||||
expect(resolveBearerToken).not.toHaveBeenCalled();
|
||||
// No module read, no rate-limit hit before auth fails.
|
||||
expect(isModuleEnabled).not.toHaveBeenCalled();
|
||||
expect(checkMcpRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a malformed Authorization header (not Bearer) with 401", async () => {
|
||||
const res = await POST(
|
||||
postRpc(TOOLS_LIST, { authorization: "Basic abc123" }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(resolveBearerToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a revoked / expired / unknown token with a blunt 401", async () => {
|
||||
vi.mocked(resolveBearerToken).mockRejectedValue(new Error("revoked"));
|
||||
const res = await POST(postRpc(TOOLS_LIST));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
// No oracle: the body never echoes the rejection reason or the token.
|
||||
expect(JSON.stringify(body)).not.toMatch(/revoked|expired|hlk_/);
|
||||
// A rejected token never reaches the module gate or the transport.
|
||||
expect(isModuleEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/mcp — module gate (off by default)", () => {
|
||||
it("hides the surface with 404 when the mcp module is off", async () => {
|
||||
validToken();
|
||||
vi.mocked(isModuleEnabled).mockResolvedValue(false);
|
||||
|
||||
const res = await POST(postRpc(TOOLS_LIST));
|
||||
expect(res.status).toBe(404);
|
||||
// Authenticated first (so the module is resolved per-user), then hidden.
|
||||
expect(resolveBearerToken).toHaveBeenCalledTimes(1);
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mcp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/mcp — rate limit", () => {
|
||||
it("trips 429 once the per-credential budget is exhausted", async () => {
|
||||
validToken();
|
||||
vi.mocked(checkMcpRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
const res = await POST(postRpc(TOOLS_LIST));
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get("x-ratelimit-remaining")).toBe("0");
|
||||
// The limiter is keyed by the `<userId>:<tokenId>` binding.
|
||||
expect(checkMcpRateLimit).toHaveBeenCalledWith("user-1:token-1");
|
||||
// A throttled request never reaches the module gate or the transport.
|
||||
expect(isModuleEnabled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/mcp — admin unreachable over HTTP (cookie-only boundary)", () => {
|
||||
it("never consults a cookie session, even with a wildcard token", async () => {
|
||||
validToken(["*"]);
|
||||
const res = await POST(postRpc(TOOLS_LIST));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
const names = (body.result.tools as Array<{ name: string }>).map(
|
||||
(t) => t.name,
|
||||
);
|
||||
|
||||
// A `["*"]` token carries write capability (`tokenAllowsWrite`), so it
|
||||
// sees the confirmed write tools alongside the read tools — but NO admin
|
||||
// tool exists, and admin stays unreachable here by construction.
|
||||
for (const read of READ_TOOLS) {
|
||||
expect(names).toContain(read);
|
||||
}
|
||||
expect(names).toContain("log_measurement");
|
||||
expect(names).toContain("log_mood");
|
||||
for (const name of names) {
|
||||
expect(name).not.toMatch(/admin/i);
|
||||
}
|
||||
|
||||
// The route authenticates purely by Bearer: a cookie session is never
|
||||
// read, so `requireAdmin()` (cookie-only) can never be reached here.
|
||||
expect(getSession).not.toHaveBeenCalled();
|
||||
expect(cookiesGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/mcp — write tools gate on health:write", () => {
|
||||
it("a health:read token lists NO write tools", async () => {
|
||||
validToken(["health:read"]);
|
||||
const res = await POST(postRpc(TOOLS_LIST));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
const names = (body.result.tools as Array<{ name: string }>).map(
|
||||
(t) => t.name,
|
||||
);
|
||||
expect(names.sort()).toEqual(READ_TOOLS);
|
||||
expect(names).not.toContain("log_measurement");
|
||||
expect(names).not.toContain("log_mood");
|
||||
});
|
||||
|
||||
it("a health:read+write token can list (and thus invoke) the write tools", async () => {
|
||||
validToken(["health:read", "health:write"]);
|
||||
const res = await POST(postRpc(TOOLS_LIST));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
const names = (body.result.tools as Array<{ name: string }>).map(
|
||||
(t) => t.name,
|
||||
);
|
||||
expect(names).toContain("log_measurement");
|
||||
expect(names).toContain("log_mood");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/mcp — GET transport", () => {
|
||||
it("is wired for GET (SSE stream open) behind the same gates", async () => {
|
||||
validToken();
|
||||
const res = await GET(
|
||||
new Request("http://localhost/mcp", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
authorization: "Bearer hlk_" + "a".repeat(64),
|
||||
accept: "text/event-stream",
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Auth + gates ran; the transport answers (a stream open or a
|
||||
// method-specific status), never a 401/404/429 on the happy path.
|
||||
expect([200, 405]).toContain(res.status);
|
||||
expect(resolveBearerToken).toHaveBeenCalledTimes(1);
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mcp");
|
||||
// Release any opened stream so the test process can exit cleanly.
|
||||
await res.body?.cancel().catch(() => {});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Remote MCP endpoint — Streamable HTTP transport.
|
||||
*
|
||||
* Exposes the SAME read-only tool / resource layer the stdio transport
|
||||
* serves (`src/lib/mcp/`) over HTTP, so a remote MCP client (Claude.ai /
|
||||
* ChatGPT / the hosted-or-iOS crowd) can connect with a manually-minted
|
||||
* `hlk_<hex>` Bearer token before full OAuth lands (ADR-002 / ADR-006). The
|
||||
* wire protocol — JSON-RPC framing, capability negotiation, the
|
||||
* GET/POST/DELETE method matrix — is the official SDK's
|
||||
* `WebStandardStreamableHTTPServerTransport`; this route only owns the four
|
||||
* HealthLog-specific gates in front of it:
|
||||
*
|
||||
* 1. AUTH — `Authorization: Bearer hlk_…` resolved by the SAME validator
|
||||
* the HTTP edge uses (`resolveMcpAuthContext` → `resolveBearerToken`),
|
||||
* so revoked / expired / scope semantics never drift between the stdio,
|
||||
* remote, and REST wires. A cookie is never consulted — the wire
|
||||
* carries none — so the cookie-only `requireAdmin()` boundary is
|
||||
* unreachable here by construction (ADR-005 / REQ-SEC-7). No token,
|
||||
* a malformed header, or a rejected token all answer one blunt 401.
|
||||
*
|
||||
* 2. RATE LIMIT — the Postgres limiter keyed by the `<userId>:<tokenId>`
|
||||
* binding (`mcp:<binding>`), so a single leaked / shared credential
|
||||
* cannot drain the account's other tokens (REQ-SEC-9 / REQ-SEC-11).
|
||||
*
|
||||
* 3. MODULE GATE — the `mcp` module is OPT-IN (off by default). With the
|
||||
* module off the surface answers 404 and reveals nothing — a
|
||||
* self-hoster who never opts in gets zero new exposure (ADR-007 /
|
||||
* REQ-OPS-1).
|
||||
*
|
||||
* 4. TRANSPORT — a fresh stateless server + transport per request
|
||||
* (`sessionIdGenerator: undefined`, `enableJsonResponse: true`), torn
|
||||
* down once the buffered JSON-RPC response is built.
|
||||
*
|
||||
* Write surface: the factory registers the confirmed write tools
|
||||
* (`log_measurement` / `log_mood`) ONLY for a `health:write`-scoped session;
|
||||
* a read-only token never sees them. Writes happen in-process here and are
|
||||
* audience-bound to `/mcp` — a `health:write` MCP token is refused on every
|
||||
* REST write/delete by the resource-server guard in `api-handler.ts`, so it
|
||||
* can never become a general REST write credential. `requireAdmin()` stays
|
||||
* cookie-only, so admin is unreachable over this wire regardless of token
|
||||
* scope — including `["*"]` (ADR-003 / ADR-005).
|
||||
*
|
||||
* Same-origin / no-CORS by construction: no `Access-Control-Allow-Origin`
|
||||
* header is emitted anywhere here, matching the app-wide posture.
|
||||
*/
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
|
||||
import { createMcpServer, resolveMcpAuthContext } from "@/lib/mcp";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { checkMcpRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
isMcpOriginConfigured,
|
||||
resolveBaseOrigin,
|
||||
} from "@/lib/mcp/oauth/config";
|
||||
import { wwwAuthenticateChallenge } from "@/lib/mcp/oauth/metadata";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
|
||||
// The Bearer resolver + module gate + rate limiter all touch Prisma, so the
|
||||
// route must run on the Node runtime, never the Edge runtime.
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MCP_MODULE: ModuleKey = "mcp";
|
||||
|
||||
/**
|
||||
* One blunt 401 for every authentication failure class — no oracle. The
|
||||
* `WWW-Authenticate` carries the RFC 9728 `resource_metadata` pointer so a
|
||||
* remote client can discover the Authorization Server and start the OAuth flow
|
||||
* (REQ-T3). An optional `scope` drives incremental consent (SEP-835).
|
||||
*/
|
||||
function unauthorized(request: Request, scope?: string): Response {
|
||||
return Response.json(
|
||||
{ error: "unauthorized" },
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
"WWW-Authenticate": wwwAuthenticateChallenge(request.url, scope),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DNS-rebinding defense (MCP spec PR #1439): reject a request whose `Origin`
|
||||
* header does not match this deployment's own origin with a 403. A non-browser
|
||||
* MCP client (Claude.ai / ChatGPT server-side) sends no `Origin`, so the absence
|
||||
* of the header is allowed; only a present, mismatched browser `Origin` is
|
||||
* refused — closing the rebinding hole without breaking the real clients.
|
||||
*/
|
||||
function originAllowed(request: Request): boolean {
|
||||
const origin = request.headers.get("origin");
|
||||
if (!origin) return true;
|
||||
return origin === resolveBaseOrigin(request.url);
|
||||
}
|
||||
|
||||
function forbiddenOrigin(): Response {
|
||||
return Response.json({ error: "forbidden_origin" }, { status: 403 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Module off → the surface does not exist for this account. A 404 (not a
|
||||
* 403) keeps the remote endpoint invisible until the operator / user opts
|
||||
* in, so a disabled instance leaks nothing about it.
|
||||
*/
|
||||
function moduleDisabled(): Response {
|
||||
return Response.json({ error: "not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
/** 429 with the standard rate-limit headers. */
|
||||
function tooManyRequests(resetAt: number, remaining: number): Response {
|
||||
const response = Response.json({ error: "rate_limited" }, { status: 429 });
|
||||
for (const [k, v] of Object.entries(
|
||||
rateLimitHeaders({ allowed: false, remaining, resetAt }),
|
||||
)) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Extract the raw token from an `Authorization: Bearer <token>` header. */
|
||||
function readBearer(request: Request): string | null {
|
||||
const header = request.headers.get("authorization");
|
||||
if (!header) return null;
|
||||
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
||||
if (!match) return null;
|
||||
const token = match[1].trim();
|
||||
return token.length > 0 ? token : null;
|
||||
}
|
||||
|
||||
async function handleMcp(request: Request): Promise<Response> {
|
||||
return withBackgroundEvent("mcp.request", async () => {
|
||||
// 0. ORIGIN — refuse a cross-origin browser request before anything else.
|
||||
if (!originAllowed(request)) {
|
||||
annotate({ action: { name: "mcp.origin.rejected" } });
|
||||
return forbiddenOrigin();
|
||||
}
|
||||
|
||||
// 0a. KILL-SWITCH + ORIGIN CONFIG — M1 fails closed without a pinned
|
||||
// origin; M4 honours the operator's global API switch. Both answer the
|
||||
// same invisible 404 as the module-off posture so a disabled instance
|
||||
// leaks nothing.
|
||||
if (!isMcpOriginConfigured() || !(await isApiGloballyEnabled())) {
|
||||
annotate({ action: { name: "mcp.surface.unavailable" } });
|
||||
return moduleDisabled();
|
||||
}
|
||||
|
||||
// 1. AUTH — Bearer only; a cookie is never consulted (admin unreachable).
|
||||
const raw = readBearer(request);
|
||||
if (!raw) {
|
||||
annotate({ action: { name: "mcp.auth.missing" } });
|
||||
return unauthorized(request);
|
||||
}
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = await resolveMcpAuthContext(raw);
|
||||
} catch {
|
||||
// Do not echo the token or the rejection reason — one blunt 401.
|
||||
annotate({ action: { name: "mcp.auth.rejected" } });
|
||||
return unauthorized(request);
|
||||
}
|
||||
|
||||
// 2. RATE LIMIT — per `<userId>:<tokenId>` credential binding.
|
||||
const rl = await checkMcpRateLimit(ctx.binding);
|
||||
if (!rl.allowed) {
|
||||
annotate({ action: { name: "mcp.rate_limited" } });
|
||||
return tooManyRequests(rl.resetAt, rl.remaining);
|
||||
}
|
||||
|
||||
// 3. MODULE GATE — off by default; hide the surface entirely when off.
|
||||
if (!(await isModuleEnabled(ctx.userId, MCP_MODULE))) {
|
||||
annotate({ action: { name: "mcp.module.disabled" } });
|
||||
return moduleDisabled();
|
||||
}
|
||||
|
||||
// 4. TRANSPORT — stateless: one fresh server + transport per request.
|
||||
annotate({
|
||||
action: { name: "mcp.request.served" },
|
||||
meta: { method: request.method },
|
||||
});
|
||||
|
||||
const server = createMcpServer(ctx);
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
// Stateless mode — no server-held session; each request stands alone.
|
||||
sessionIdGenerator: undefined,
|
||||
// Buffer a single JSON-RPC response instead of opening an SSE stream;
|
||||
// the read tools return one structured result per call.
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await server.connect(transport);
|
||||
// In JSON-response stateless mode the returned Response is fully
|
||||
// buffered before this resolves, so tearing the transport down in the
|
||||
// `finally` cannot truncate it.
|
||||
return await transport.handleRequest(request);
|
||||
} finally {
|
||||
await transport.close().catch(() => {});
|
||||
await server.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const GET = handleMcp;
|
||||
export const POST = handleMcp;
|
||||
export const DELETE = handleMcp;
|
||||
@@ -17,6 +17,7 @@ import { IntegrationsSection } from "@/components/settings/integrations-section"
|
||||
import { LabsSection } from "@/components/settings/labs-section";
|
||||
import { LayoutSection } from "@/components/settings/layout-section";
|
||||
import { MedicationsSection } from "@/components/settings/medications-section";
|
||||
import { McpSection } from "@/components/settings/mcp-section";
|
||||
import { ModulesSection } from "@/components/settings/modules-section";
|
||||
import { MoodSection } from "@/components/settings/mood-section";
|
||||
import { NotificationsSection } from "@/components/settings/notifications-section";
|
||||
@@ -76,6 +77,7 @@ const SECTION_COMPONENTS: Record<
|
||||
vorsorge: VorsorgeSection,
|
||||
thresholds: ThresholdsSection,
|
||||
api: ApiSection,
|
||||
mcp: McpSection,
|
||||
gesundheitsakte: GesundheitsakteSection,
|
||||
sharing: SharingSection,
|
||||
export: ExportSection,
|
||||
|
||||
Reference in new issue
Block a user