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:
Marc-André Bombeck committed 2026-06-28 00:52:04 +02:00
commit fb986bc09d
89 files changed
+10276 -109

No files matched your search

+41
View File
@@ -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 });
},
);
+26
View File
@@ -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");
});
});
+456
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 } : {}),
});
});
}
+138
View File
@@ -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 },
);
});
}
+411
View File
@@ -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",
);
});
}
+44
View File
@@ -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("*");
}
});
});
+122
View File
@@ -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,
);
});