diff --git a/src/app/.well-known/oauth-authorization-server/route.ts b/src/app/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 000000000..f52c79d27 --- /dev/null +++ b/src/app/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,18 @@ +/** + * 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"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export function GET(request: Request): Response { + return Response.json(authorizationServerMetadata(request.url), { + headers: { "Cache-Control": "public, max-age=300" }, + }); +} diff --git a/src/app/.well-known/oauth-protected-resource/route.ts b/src/app/.well-known/oauth-protected-resource/route.ts new file mode 100644 index 000000000..46a4a79fb --- /dev/null +++ b/src/app/.well-known/oauth-protected-resource/route.ts @@ -0,0 +1,19 @@ +/** + * 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"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export function GET(request: Request): Response { + return Response.json(protectedResourceMetadata(request.url), { + headers: { "Cache-Control": "public, max-age=300" }, + }); +} diff --git a/src/app/api/mcp/oauth/__tests__/authorize.test.ts b/src/app/api/mcp/oauth/__tests__/authorize.test.ts new file mode 100644 index 000000000..fc65471c3 --- /dev/null +++ b/src/app/api/mcp/oauth/__tests__/authorize.test.ts @@ -0,0 +1,218 @@ +/** + * 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(() => ({})), +})); + +import { GET, POST } from "../authorize/route"; +import { getSession } from "@/lib/auth/session"; +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 { + const params: Record = { + 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): 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); +}); + +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("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"); + }); +}); diff --git a/src/app/api/mcp/oauth/__tests__/register.test.ts b/src/app/api/mcp/oauth/__tests__/register.test.ts new file mode 100644 index 000000000..bcf37c2be --- /dev/null +++ b/src/app/api/mcp/oauth/__tests__/register.test.ts @@ -0,0 +1,72 @@ +/** + * 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(() => ({})), +})); + +import { POST } from "../register/route"; + +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()); + +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); + }); +}); diff --git a/src/app/api/mcp/oauth/__tests__/token.test.ts b/src/app/api/mcp/oauth/__tests__/token.test.ts new file mode 100644 index 000000000..c48bf9872 --- /dev/null +++ b/src/app/api/mcp/oauth/__tests__/token.test.ts @@ -0,0 +1,204 @@ +/** + * 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(() => ({})), +})); + +import { randomUUID } from "node:crypto"; +import { POST } from "../token/route"; +import { issueApiToken } from "@/lib/auth/issue-token"; +import { checkRateLimit } from "@/lib/rate-limit"; +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 { + 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): 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, + }); +}); + +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("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", async () => { + const refresh = 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: refresh, + 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_/); + }); +}); + +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"); + }); +}); diff --git a/src/app/api/mcp/oauth/authorize/route.ts b/src/app/api/mcp/oauth/authorize/route.ts new file mode 100644 index 000000000..068fe7974 --- /dev/null +++ b/src/app/api/mcp/oauth/authorize/route.ts @@ -0,0 +1,363 @@ +/** + * 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 { + audienceMatches, + AUTH_CODE_TTL_SECONDS, + SCOPE_HEALTH_READ, +} 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, "'"); +} + +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( + `HealthLog — Authorize access${body}`, + { + status, + headers: { "content-type": "text/html; charset=utf-8" }, + }, + ); +} + +/** The single scope this read surface grants, plus optional refresh continuity. */ +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("offline_access")) granted.push("offline_access"); + return granted.join(" "); +} + +interface ValidatedRequest { + clientId: string; + clientName: string; + 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, + 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", + ), + }; + } + + return { + ok: true, + v: { + clientId: p.client_id, + clientName: resolved.client.clientName, + redirectUri: p.redirect_uri, + codeChallenge: p.code_challenge as string, + scope: grantScope(p.scope), + state: p.state, + resource: p.resource as string, + }, + }; +} + +function redirectBack( + redirectUri: string, + query: Record, +): 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); +} + +export async function GET(request: NextRequest): Promise { + return withBackgroundEvent("mcp.oauth.authorize", async () => { + 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( + `

Sign in to HealthLog

To connect ${htmlEscape(v.clientName)} you must sign in first.

Sign in and continue

`, + ); + } + + 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) => + ``; + return html( + `
+

Authorize access

+

${htmlEscape(v.clientName)} is requesting access to your HealthLog data.

+

Scope: ${htmlEscape(v.scope)} — read-only access to your own health records.

+
+ ${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) : ""} + + +
+
`, + ); + }); +} + +export async function POST(request: NextRequest): Promise { + return withBackgroundEvent("mcp.oauth.authorize", async () => { + 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 = {}; + 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, + 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 } : {}), + }); + }); +} diff --git a/src/app/api/mcp/oauth/register/route.ts b/src/app/api/mcp/oauth/register/route.ts new file mode 100644 index 000000000..9e49e8192 --- /dev/null +++ b/src/app/api/mcp/oauth/register/route.ts @@ -0,0 +1,144 @@ +/** + * 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 { 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; + +/** A redirect URI must be an absolute HTTPS URL or an http loopback URL. */ +function isAllowableRedirectUri(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + if (url.protocol === "https:") return true; + if ( + url.protocol === "http:" && + ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname) + ) { + return true; + } + return false; +} + +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 = {}, +): Response { + return Response.json( + { error, error_description: description }, + { status, headers: extraHeaders }, + ); +} + +export async function POST(request: NextRequest): Promise { + 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, + }), + }, + ); + } + + const raw = await request.text(); + if (raw.length > MAX_BODY_BYTES) { + return oauthError("invalid_client_metadata", "Payload too large", 400); + } + let body: unknown; + try { + body = JSON.parse(raw); + } 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 }, + ); + }); +} diff --git a/src/app/api/mcp/oauth/token/route.ts b/src/app/api/mcp/oauth/token/route.ts new file mode 100644 index 000000000..0d8d628dd --- /dev/null +++ b/src/app/api/mcp/oauth/token/route.ts @@ -0,0 +1,316 @@ +/** + * Token endpoint (OAuth 2.1) — `authorization_code` + `refresh_token` grants. + * + * Exchanges the bridge AS's self-describing artifacts for a REAL `hlk_` + * `ApiToken` (the access token) minted through `issue-token.ts` with the narrow + * `health:read` scope (ADR-006). The access token is therefore an ordinary + * Bearer the `/mcp` resolver already understands — bound to `:` + * (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 { + checkRateLimit, + checkAuthSurfaceRateLimit, + rateLimitHeaders, +} from "@/lib/rate-limit"; +import { + ACCESS_TOKEN_TTL_MINUTES, + audienceMatches, + REFRESH_TOKEN_TTL_DAYS, + SCOPE_HEALTH_READ, +} from "@/lib/mcp/oauth/config"; +import { signArtifact, verifyArtifact } from "@/lib/mcp/oauth/artifacts"; +import { verifyPkceS256 } from "@/lib/mcp/oauth/pkce"; + +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): 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 { + 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; + redirect_uri: string; + code_challenge: string; + scope: string; + resource: string; +} + +interface RefreshClaims { + jti: string; + sub: string; + client_id: string; + scope: string; + resource: string; +} + +async function mintTokenPair(args: { + userId: string; + clientId: string; + scope: string; + resource: string; + grant: "authorization_code" | "refresh_token"; +}): Promise { + const access = await issueApiToken({ + userId: args.userId, + name: "MCP connector", + permissions: [SCOPE_HEALTH_READ], + expiresInMinutes: ACCESS_TOKEN_TTL_MINUTES, + }); + + const includeRefresh = args.scope.split(/\s+/).includes("offline_access"); + const refresh = includeRefresh + ? signArtifact( + "refreshToken", + { + jti: randomUUID(), + 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 { + return withBackgroundEvent("mcp.oauth.token", async () => { + 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("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", + ); + } + + return mintTokenPair({ + userId: claims.sub, + clientId: claims.client_id, + scope: claims.scope, + resource: claims.resource, + grant: "authorization_code", + }); + } + + 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( + "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", + ); + } + // Rotation: claim the presented refresh jti so it cannot be reused. + if ( + !(await claimJti( + "refresh", + claims.jti, + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000, + )) + ) { + annotate({ action: { name: "mcp.oauth.token.refresh_replayed" } }); + return oauthError( + "invalid_grant", + "Refresh token has already been used", + ); + } + + return mintTokenPair({ + userId: claims.sub, + clientId: claims.client_id, + scope: claims.scope, + resource: claims.resource, + grant: "refresh_token", + }); + } + + return oauthError( + "unsupported_grant_type", + "Only authorization_code and refresh_token are supported", + ); + }); +} diff --git a/src/lib/auth/issue-token.ts b/src/lib/auth/issue-token.ts index 214cb8732..bf4375bf2 100644 --- a/src/lib/auth/issue-token.ts +++ b/src/lib/auth/issue-token.ts @@ -23,6 +23,12 @@ export interface IssueTokenOptions { permissions?: string[]; /** Lifetime in days. */ expiresInDays?: number; + /** + * Lifetime in minutes. When set it WINS over `expiresInDays` — used for the + * short-lived OAuth access tokens the MCP bridge mints (the refresh token + * carries continuity). Ignored when `expiresInDays` alone is supplied. + */ + expiresInMinutes?: number; } /** @@ -35,9 +41,10 @@ export async function issueApiToken( ): Promise { const rawToken = `hlk_${randomBytes(32).toString("hex")}`; const tokenHashValue = hashToken(rawToken); - const expiresAt = new Date( - Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000, - ); + const expiresAt = + opts.expiresInMinutes !== undefined + ? new Date(Date.now() + opts.expiresInMinutes * 60 * 1000) + : new Date(Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000); const created = await prisma.apiToken.create({ data: { diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index 6aaa70f0b..90e7649a7 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -89,6 +89,14 @@ export function redactSecrets(input: string): string { // these prefixes (CLAUDE.md headless-client-API patterns); this // is the matching egress guard for log/error surfaces. .replace(/(^|[^A-Za-z0-9])hl[kr]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]") + // v1.22.0 — the MCP OAuth bridge artifacts: the authorization code + // (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All + // are signed, base64url-bodied, and credential-bearing, so they are + // scrubbed at the egress boundary like the native token formats above. + .replace( + /(^|[^A-Za-z0-9])hl(?:ac|rt|c)_[A-Za-z0-9_.-]{16,}/g, + "$1[REDACTED]", + ) // v1.7.0 — `insurance`/`insuranceNumber`/`kvnr` (German statutory- // insurance number) added as defence-in-depth: the value is // encrypted at rest and never deliberately logged, but a stray diff --git a/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts b/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts new file mode 100644 index 000000000..e923dc7fa --- /dev/null +++ b/src/lib/mcp/oauth/__tests__/oauth-bridge.test.ts @@ -0,0 +1,216 @@ +/** + * OAuth bridge — library-level unit tests (artifacts, PKCE, clients, metadata, + * audience). These pin the security-load-bearing primitives without a DB. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// A fixed signing key so artifact MACs are deterministic across the run. +process.env.API_TOKEN_HMAC_KEY = "x".repeat(48); +process.env.APP_URL = "https://health.example"; + +vi.mock("@/lib/safe-fetch", () => ({ + safeFetch: vi.fn(), + SafeFetchError: class SafeFetchError extends Error {}, +})); + +import { safeFetch } from "@/lib/safe-fetch"; +import { signArtifact, verifyArtifact } from "../artifacts"; +import { s256Challenge, verifyPkceS256, isValidVerifier } from "../pkce"; +import { + registerDcrClient, + resolveClient, + redirectUriAllowed, +} from "../clients"; +import { + protectedResourceMetadata, + authorizationServerMetadata, + wwwAuthenticateChallenge, +} from "../metadata"; +import { audienceMatches, canonicalResource } from "../config"; + +describe("artifacts — sign / verify / tamper / expiry / domain separation", () => { + it("round-trips claims and enforces expiry", () => { + const token = signArtifact("authCode", { sub: "u1" }, 60_000); + const ok = verifyArtifact<{ sub: string }>("authCode", token); + expect(ok.ok).toBe(true); + if (ok.ok) expect(ok.claims.sub).toBe("u1"); + }); + + it("rejects an expired artifact", () => { + const token = signArtifact("authCode", { sub: "u1" }, -1); + const res = verifyArtifact("authCode", token); + expect(res).toEqual({ ok: false, reason: "expired" }); + }); + + it("rejects a tampered payload (bad signature)", () => { + const token = signArtifact("authCode", { sub: "u1" }, 60_000); + // Flip a character in the payload segment. + const [prefixAndPayload, sig] = [ + token.slice(0, token.lastIndexOf(".")), + token.slice(token.lastIndexOf(".") + 1), + ]; + const tampered = `${prefixAndPayload}A.${sig}`; + const res = verifyArtifact("authCode", tampered); + expect(res.ok).toBe(false); + }); + + it("domain-separates the three artifact classes", () => { + const code = signArtifact("authCode", { sub: "u1" }, 60_000); + // An auth code must not verify as a refresh token or a client id. + expect(verifyArtifact("refreshToken", code).ok).toBe(false); + expect(verifyArtifact("clientId", code).ok).toBe(false); + }); +}); + +describe("PKCE — S256 only", () => { + it("verifies a correct verifier against its challenge", () => { + const verifier = "a".repeat(64); + const challenge = s256Challenge(verifier); + expect(verifyPkceS256(verifier, challenge)).toBe(true); + }); + + it("rejects a wrong verifier", () => { + const challenge = s256Challenge("a".repeat(64)); + expect(verifyPkceS256("b".repeat(64), challenge)).toBe(false); + }); + + it("rejects a too-short verifier (RFC 7636 §4.1)", () => { + expect(isValidVerifier("short")).toBe(false); + expect(verifyPkceS256("short", s256Challenge("short"))).toBe(false); + }); +}); + +describe("clients — DCR (stateless) round-trip", () => { + it("registers and resolves a DCR client with its redirect URIs", async () => { + const reg = registerDcrClient({ + clientName: "Claude", + redirectUris: ["https://claude.ai/api/mcp/auth_callback"], + }); + expect(reg.clientId.startsWith("hlc_")).toBe(true); + + const resolved = await resolveClient(reg.clientId); + expect(resolved.ok).toBe(true); + if (resolved.ok) { + expect(resolved.client.source).toBe("dcr"); + expect(resolved.client.redirectUris).toEqual([ + "https://claude.ai/api/mcp/auth_callback", + ]); + } + }); + + it("rejects an unknown client id", async () => { + const res = await resolveClient("not-a-client"); + expect(res.ok).toBe(false); + }); +}); + +describe("clients — CIMD via safeFetch (SSRF-safe)", () => { + beforeEach(() => vi.clearAllMocks()); + + it("fetches a CIMD document through safeFetch with requirePublicHost", async () => { + const clientId = "https://app.example/mcp-client.json"; + vi.mocked(safeFetch).mockResolvedValue( + new Response( + JSON.stringify({ + client_id: clientId, + client_name: "ChatGPT", + redirect_uris: [ + "https://chatgpt.com/connector_platform_oauth_redirect", + ], + }), + { status: 200 }, + ), + ); + + const res = await resolveClient(clientId); + expect(res.ok).toBe(true); + // The SSRF guard is requested. + const opts = vi.mocked(safeFetch).mock.calls[0][2]; + expect(opts?.requirePublicHost).toBe(true); + }); + + it("rejects a CIMD doc whose client_id does not match its URL (SEP-991)", async () => { + const clientId = "https://app.example/mcp-client.json"; + vi.mocked(safeFetch).mockResolvedValue( + new Response( + JSON.stringify({ + client_id: "https://attacker.example/other.json", + redirect_uris: ["https://app.example/cb"], + }), + { status: 200 }, + ), + ); + const res = await resolveClient(clientId); + expect(res).toEqual({ ok: false, reason: "invalid_metadata" }); + }); + + it("surfaces an SSRF block as ssrf_blocked", async () => { + vi.mocked(safeFetch).mockRejectedValue(new Error("private host")); + const res = await resolveClient("https://169.254.169.254/meta.json"); + expect(res).toEqual({ ok: false, reason: "ssrf_blocked" }); + }); +}); + +describe("clients — redirect URI matching", () => { + it("matches an exact registered URI (claude.ai callback)", () => { + const registered = ["https://claude.ai/api/mcp/auth_callback"]; + expect( + redirectUriAllowed("https://claude.ai/api/mcp/auth_callback", registered), + ).toBe(true); + }); + + it("matches loopback port-agnostically", () => { + const registered = ["http://localhost:1234/callback"]; + expect( + redirectUriAllowed("http://localhost:55999/callback", registered), + ).toBe(true); + expect( + redirectUriAllowed("http://127.0.0.1:1/callback", [ + "http://127.0.0.1:9/callback", + ]), + ).toBe(true); + }); + + it("rejects a non-loopback mismatch and a path mismatch", () => { + expect( + redirectUriAllowed("https://evil.example/cb", [ + "https://claude.ai/api/mcp/auth_callback", + ]), + ).toBe(false); + expect( + redirectUriAllowed("http://localhost:1/evil", [ + "http://localhost:2/callback", + ]), + ).toBe(false); + }); +}); + +describe("metadata + audience", () => { + it("PRM names the canonical /mcp resource", () => { + const prm = protectedResourceMetadata(); + expect(prm.resource).toBe("https://health.example/mcp"); + expect(prm.authorization_servers).toEqual(["https://health.example"]); + }); + + it("AS metadata advertises S256-only PKCE + CIMD + none auth", () => { + const as = authorizationServerMetadata(); + expect(as.code_challenge_methods_supported).toEqual(["S256"]); + expect(as.token_endpoint_auth_methods_supported).toEqual(["none"]); + expect(as.client_id_metadata_document_supported).toBe(true); + expect(as.grant_types_supported).toContain("authorization_code"); + expect(as.grant_types_supported).toContain("refresh_token"); + }); + + it("the WWW-Authenticate challenge points at the PRM", () => { + expect(wwwAuthenticateChallenge()).toMatch( + /resource_metadata="https:\/\/health\.example\/\.well-known\/oauth-protected-resource"/, + ); + }); + + it("audience binding accepts the canonical resource and rejects look-alikes", () => { + expect(audienceMatches(canonicalResource())).toBe(true); + expect(audienceMatches("https://health.example/mcp/")).toBe(true); + expect(audienceMatches("https://evil.example/mcp")).toBe(false); + expect(audienceMatches(undefined)).toBe(false); + }); +}); diff --git a/src/lib/mcp/oauth/artifacts.ts b/src/lib/mcp/oauth/artifacts.ts new file mode 100644 index 000000000..510c2688a --- /dev/null +++ b/src/lib/mcp/oauth/artifacts.ts @@ -0,0 +1,128 @@ +/** + * Signed, self-describing OAuth artifacts (stateless bridge). + * + * The minimal AS keeps NO new identity tables (ADR-006). The three short-lived + * OAuth artifacts it mints — the authorization code, the refresh token, and the + * Dynamic-Client-Registration client id — are instead HMAC-signed, tamper-proof, + * self-describing strings. Each carries its own claims and is verified by + * recomputing the signature in constant time; nothing is looked up in a table. + * + * Only the ACCESS token is a real persisted row: it is an ordinary `hlk_` + * `ApiToken` minted through `issue-token.ts`, so the existing revoke / expiry / + * `lastUsedAt` machinery and the `:` binding all apply to it + * unchanged. The artifacts here exist only to carry grant state BETWEEN the + * authorize and token steps without inventing a parallel store. + * + * Signing key: `API_TOKEN_HMAC_KEY` (the same fail-closed key the Bearer hasher + * uses — `hashToken` validates length ≥ 32). Each artifact class is domain- + * separated by a context label folded into the MAC input, so a code can never be + * presented as a refresh token or a client id, and vice-versa. + * + * The signed value is `..`. The + * payload always carries an `exp` (epoch ms) which `verifyArtifact` enforces. + */ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** Artifact classes, each with a stable prefix + domain-separation label. */ +export const ARTIFACT_KINDS = { + authCode: { prefix: "hlac_", context: "mcp.oauth.auth_code.v1" }, + refreshToken: { prefix: "hlrt_", context: "mcp.oauth.refresh.v1" }, + clientId: { prefix: "hlc_", context: "mcp.oauth.client.v1" }, +} as const; + +export type ArtifactKind = keyof typeof ARTIFACT_KINDS; + +function signingKey(): string { + const key = process.env.API_TOKEN_HMAC_KEY; + if (!key || key.length < 32) { + // Fail closed — never sign an OAuth artifact off weak / missing key + // material (mirrors `hashToken`). + throw new Error( + "API_TOKEN_HMAC_KEY must be set and at least 32 characters", + ); + } + return key; +} + +function b64url(input: Buffer | string): string { + return Buffer.from(input) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function b64urlDecode(input: string): Buffer { + return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/"), "base64"); +} + +function mac(context: string, payloadB64: string): string { + return createHmac("sha256", signingKey()) + .update(`${context}.${payloadB64}`) + .digest("hex"); +} + +/** + * Mint a signed artifact. `claims` is merged with `exp` (now + `ttlMs`) and + * serialised; the caller never sets `exp` itself. + */ +export function signArtifact( + kind: ArtifactKind, + claims: Record, + ttlMs: number, +): string { + const { prefix, context } = ARTIFACT_KINDS[kind]; + const payload = { ...claims, exp: Date.now() + ttlMs }; + const payloadB64 = b64url(JSON.stringify(payload)); + // `mac` returns hex; base64url the RAW bytes so `verifyArtifact` can compare + // against `Buffer.from(expectedSig, "hex")` byte-for-byte. + const sig = b64url(Buffer.from(mac(context, payloadB64), "hex")); + return `${prefix}${payloadB64}.${sig}`; +} + +export type VerifyResult = + | { ok: true; claims: T } + | { ok: false; reason: "malformed" | "bad_signature" | "expired" }; + +/** + * Verify + decode a signed artifact. Returns the claims on success, or a stable + * machine reason on failure. The signature is checked in constant time and the + * `exp` claim is enforced. A wrong-kind artifact fails on `malformed` (prefix + * mismatch) or `bad_signature` (domain-separation label mismatch). + */ +export function verifyArtifact>( + kind: ArtifactKind, + value: string, +): VerifyResult { + const { prefix, context } = ARTIFACT_KINDS[kind]; + if (typeof value !== "string" || !value.startsWith(prefix)) { + return { ok: false, reason: "malformed" }; + } + const body = value.slice(prefix.length); + const dot = body.lastIndexOf("."); + if (dot <= 0) return { ok: false, reason: "malformed" }; + + const payloadB64 = body.slice(0, dot); + const sigB64 = body.slice(dot + 1); + + const expectedSig = mac(context, payloadB64); + const presented = b64urlDecode(sigB64); + const expected = Buffer.from(expectedSig, "hex"); + if ( + presented.length !== expected.length || + !timingSafeEqual(presented, expected) + ) { + return { ok: false, reason: "bad_signature" }; + } + + let claims: T & { exp?: number }; + try { + claims = JSON.parse(b64urlDecode(payloadB64).toString("utf8")); + } catch { + return { ok: false, reason: "malformed" }; + } + if (typeof claims.exp !== "number" || claims.exp <= Date.now()) { + return { ok: false, reason: "expired" }; + } + return { ok: true, claims }; +} diff --git a/src/lib/mcp/oauth/clients.ts b/src/lib/mcp/oauth/clients.ts new file mode 100644 index 000000000..74bcf2624 --- /dev/null +++ b/src/lib/mcp/oauth/clients.ts @@ -0,0 +1,222 @@ +/** + * OAuth client resolution — CIMD-first, DCR fallback (Phase 3 of the MCP + * milestone; changes B's DCR-first plan per the deep-value research). + * + * Both Claude.ai and ChatGPT now prefer **CIMD** (Client ID Metadata Documents, + * SEP-991): the `client_id` IS an HTTPS URL that resolves to a JSON metadata + * document describing the client. CIMD removes the per-connection client-row + * explosion that DCR causes — and, here, removes the need for ANY client store. + * A CIMD client is resolved by fetching its document THROUGH `safeFetch` + * (SSRF-safe: `requirePublicHost`, manual redirects, bounded timeout) so a + * malicious `client_id` cannot point the AS at an internal host (R-SEC-6). + * + * For clients that still use **DCR** (RFC 7591) we register them statelessly: + * the issued `client_id` is itself a signed, self-describing `hlc_` artifact + * (see `artifacts.ts`) carrying the registered `redirect_uris` + name. Decoding + * it locally recovers the registration with no table — and tampering with it + * fails the HMAC. + * + * Redirect-URI matching is exact against the registered set, with ONE tolerated + * relaxation: loopback redirect URIs match port-agnostically (Claude Code / + * Desktop bind an ephemeral localhost port), per the connector spec. The + * `https://claude.ai/api/mcp/auth_callback` hosted callback is matched as an + * ordinary registered entry. + */ +import { safeFetch } from "@/lib/safe-fetch"; +import { ARTIFACT_KINDS, signArtifact, verifyArtifact } from "./artifacts"; + +export interface ResolvedClient { + clientId: string; + clientName: string; + redirectUris: string[]; + /** CIMD + DCR public clients authenticate with `none` (PKCE is the proof). */ + isPublic: true; + source: "cimd" | "dcr"; +} + +export type ClientResolution = + | { ok: true; client: ResolvedClient } + | { + ok: false; + reason: "unknown_client" | "invalid_metadata" | "ssrf_blocked"; + }; + +const MAX_REDIRECT_URIS = 12; +const CIMD_MAX_BYTES = 16 * 1024; + +function asStringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const out: string[] = []; + for (const v of value) { + if (typeof v !== "string" || v.length === 0 || v.length > 2048) return null; + out.push(v); + } + if (out.length === 0 || out.length > MAX_REDIRECT_URIS) return null; + return out; +} + +/** A `client_id` that is an HTTPS URL is a CIMD client (SEP-991). */ +export function isCimdClientId(clientId: string): boolean { + try { + return new URL(clientId).protocol === "https:"; + } catch { + return false; + } +} + +/** A `client_id` minted by our stateless DCR endpoint. */ +export function isDcrClientId(clientId: string): boolean { + return clientId.startsWith(ARTIFACT_KINDS.clientId.prefix); +} + +/** + * Register a client via DCR (RFC 7591). The returned `client_id` is a signed + * `hlc_` artifact that encodes the registration — no row is written. DCR clients + * never expire (the artifact has a long horizon) but carry no secret. + */ +export function registerDcrClient(input: { + clientName: string; + redirectUris: string[]; +}): ResolvedClient { + const clientId = signArtifact( + "clientId", + { name: input.clientName, redirect_uris: input.redirectUris }, + // Ten-year horizon: a registration is long-lived but still self-expiring. + 10 * 365 * 24 * 60 * 60 * 1000, + ); + return { + clientId, + clientName: input.clientName, + redirectUris: input.redirectUris, + isPublic: true, + source: "dcr", + }; +} + +function resolveDcrClient(clientId: string): ClientResolution { + const verified = verifyArtifact<{ name?: string; redirect_uris?: unknown }>( + "clientId", + clientId, + ); + if (!verified.ok) return { ok: false, reason: "unknown_client" }; + const redirectUris = asStringArray(verified.claims.redirect_uris); + if (!redirectUris) return { ok: false, reason: "invalid_metadata" }; + return { + ok: true, + client: { + clientId, + clientName: + typeof verified.claims.name === "string" + ? verified.claims.name + : "MCP client", + redirectUris, + isPublic: true, + source: "dcr", + }, + }; +} + +async function resolveCimdClient(clientId: string): Promise { + let res: Response; + try { + // SSRF floor: the metadata host is fully user-controlled, so pin the + // connect-time public-host check + manual redirects + bounded timeout. + res = await safeFetch( + clientId, + { method: "GET", headers: { accept: "application/json" } }, + { requirePublicHost: true, timeoutMs: 8000 }, + ); + } catch { + return { ok: false, reason: "ssrf_blocked" }; + } + if (!res.ok) return { ok: false, reason: "unknown_client" }; + + const raw = await res.text(); + if (raw.length > CIMD_MAX_BYTES) { + return { ok: false, reason: "invalid_metadata" }; + } + let doc: Record; + try { + doc = JSON.parse(raw); + } catch { + return { ok: false, reason: "invalid_metadata" }; + } + + // SEP-991: the document's `client_id` MUST equal the URL it was fetched from. + if (doc.client_id !== clientId) { + return { ok: false, reason: "invalid_metadata" }; + } + const redirectUris = asStringArray(doc.redirect_uris); + if (!redirectUris) return { ok: false, reason: "invalid_metadata" }; + + return { + ok: true, + client: { + clientId, + clientName: + typeof doc.client_name === "string" ? doc.client_name : "MCP client", + redirectUris, + isPublic: true, + source: "cimd", + }, + }; +} + +/** + * Resolve a `client_id` to its registration. HTTPS URLs are CIMD (fetched + * SSRF-safely); `hlc_` ids are stateless DCR; anything else is unknown. + */ +export async function resolveClient( + clientId: string, +): Promise { + if (typeof clientId !== "string" || clientId.length === 0) { + return { ok: false, reason: "unknown_client" }; + } + if (isCimdClientId(clientId)) return resolveCimdClient(clientId); + if (isDcrClientId(clientId)) return resolveDcrClient(clientId); + return { ok: false, reason: "unknown_client" }; +} + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +function isLoopback(url: URL): boolean { + return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname); +} + +/** + * Whether `requested` is an allowed redirect URI for `registered`. Exact match + * by default; loopback URIs match port-agnostically (the AS ignores the port for + * `localhost` / `127.0.0.1` / `::1`, per the connector spec) so Claude Code / + * Desktop's ephemeral callback port still matches its registration. + */ +export function redirectUriAllowed( + requested: string, + registered: readonly string[], +): boolean { + if (registered.includes(requested)) return true; + + let req: URL; + try { + req = new URL(requested); + } catch { + return false; + } + if (!isLoopback(req)) return false; + + for (const entry of registered) { + let reg: URL; + try { + reg = new URL(entry); + } catch { + continue; + } + if ( + isLoopback(reg) && + reg.hostname === req.hostname && + reg.pathname === req.pathname + ) { + return true; // ignore the port for loopback + } + } + return false; +} diff --git a/src/lib/mcp/oauth/config.ts b/src/lib/mcp/oauth/config.ts new file mode 100644 index 000000000..b36eaec5b --- /dev/null +++ b/src/lib/mcp/oauth/config.ts @@ -0,0 +1,88 @@ +/** + * OAuth bridge configuration — origin + canonical resource resolution. + * + * The MCP authorization surface is a minimal in-repo Authorization Server that + * BRIDGES onto HealthLog's existing `hlk_` Bearer model (ADR-006) — it keeps no + * parallel identity store. Every metadata document, redirect-URI match, and + * audience check is anchored to ONE canonical origin so a token minted for this + * deployment can never be replayed against another (RFC 8707 audience binding). + * + * Origin precedence mirrors the passkey RP-origin / invite-URL resolution: the + * operator-configured `APP_URL` / `NEXT_PUBLIC_APP_URL` win over the request + * origin (which may be an internal hostname behind the reverse proxy). The + * canonical resource identifier is the `/mcp` endpoint exactly — the same URL a + * user pastes into Claude.ai / ChatGPT — so the Protected Resource Metadata + * `resource` value matches the user-entered URL (a Claude.ai requirement). + */ + +/** + * Resolve the public origin of this deployment, e.g. `https://health.example`. + * Operator config wins; the request URL is the last resort. + */ +export function resolveBaseOrigin(requestUrl?: string): string { + const candidates = [ + process.env.APP_URL, + process.env.NEXT_PUBLIC_APP_URL, + requestUrl, + ] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + + for (const candidate of candidates) { + try { + return new URL(candidate).origin; + } catch { + // try the next candidate + } + } + return "http://localhost:3000"; +} + +/** + * The canonical MCP resource identifier (RFC 8707 audience). This is the value + * the AS binds every issued token to and the value a client MUST send as + * `resource=` — any other value is rejected. + */ +export function canonicalResource(requestUrl?: string): string { + return `${resolveBaseOrigin(requestUrl)}/mcp`; +} + +/** + * Whether a client-supplied `resource` value matches our canonical resource. + * Trailing slashes are tolerated (`/mcp` vs `/mcp/`) but the origin + path must + * otherwise be exact — no substring / prefix matching that could admit a + * look-alike host (RFC 8707, R-SEC-5). + */ +export function audienceMatches( + resource: string | null | undefined, + requestUrl?: string, +): boolean { + if (!resource) return false; + const want = canonicalResource(requestUrl).replace(/\/+$/, ""); + const got = resource.trim().replace(/\/+$/, ""); + return want === got; +} + +/** The single scope the read MCP surface issues. */ +export const SCOPE_HEALTH_READ = "health:read"; + +/** + * Scopes the AS advertises + will issue. `health:write` is advertised so a + * future incremental-consent (SEP-835) challenge can request it, but the read + * surface only ever issues `health:read`. `offline_access` enables refresh + * tokens (OAuth 2.1 public-client rotation) — Claude appends it when advertised. + */ +export const SUPPORTED_SCOPES = [ + SCOPE_HEALTH_READ, + "health:write", + "offline_access", +] as const; + +/** Access-token lifetime (minutes). Short-lived; refresh tokens carry continuity. */ +export const ACCESS_TOKEN_TTL_MINUTES = 60; + +/** Refresh-token lifetime (days). */ +export const REFRESH_TOKEN_TTL_DAYS = 30; + +/** Authorization-code lifetime (seconds). Single-redemption, short window. */ +export const AUTH_CODE_TTL_SECONDS = 120; diff --git a/src/lib/mcp/oauth/metadata.ts b/src/lib/mcp/oauth/metadata.ts new file mode 100644 index 000000000..ef2f4412a --- /dev/null +++ b/src/lib/mcp/oauth/metadata.ts @@ -0,0 +1,92 @@ +/** + * OAuth discovery documents — RFC 9728 (Protected Resource Metadata) + RFC 8414 + * (Authorization Server Metadata). + * + * These two JSON documents are how a remote MCP client bootstraps the flow from + * nothing but the pasted `/mcp` URL: + * + * 1. The client calls `/mcp`, gets a `401` whose `WWW-Authenticate` points at + * `/.well-known/oauth-protected-resource` (PRM). + * 2. PRM names this `resource` (the canonical `/mcp` URI — it MUST match the + * user-entered URL) and lists its `authorization_servers`. + * 3. The client fetches the AS metadata and learns the `/authorize` + `/token` + * + `/register` endpoints, that **PKCE S256 is mandatory**, and that the AS + * accepts **CIMD** clients (`client_id_metadata_document_supported: true` + + * `none` auth) with **DCR** as a fallback. + * + * Everything is anchored to the canonical origin (`config.ts`) so the documents + * are deterministic and the audience can be validated against them. + */ +import { + canonicalResource, + resolveBaseOrigin, + SUPPORTED_SCOPES, +} from "./config"; + +export interface ProtectedResourceMetadata { + resource: string; + authorization_servers: string[]; + scopes_supported: string[]; + bearer_methods_supported: string[]; + resource_documentation?: string; +} + +export interface AuthorizationServerMetadata { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint: string; + scopes_supported: string[]; + response_types_supported: string[]; + grant_types_supported: string[]; + token_endpoint_auth_methods_supported: string[]; + code_challenge_methods_supported: string[]; + client_id_metadata_document_supported: boolean; +} + +/** RFC 9728 — the Protected Resource Metadata for the `/mcp` resource. */ +export function protectedResourceMetadata( + requestUrl?: string, +): ProtectedResourceMetadata { + const origin = resolveBaseOrigin(requestUrl); + return { + resource: canonicalResource(requestUrl), + authorization_servers: [origin], + scopes_supported: [...SUPPORTED_SCOPES], + bearer_methods_supported: ["header"], + }; +} + +/** RFC 8414 — the Authorization Server Metadata for the built-in bridge AS. */ +export function authorizationServerMetadata( + requestUrl?: string, +): AuthorizationServerMetadata { + const origin = resolveBaseOrigin(requestUrl); + return { + issuer: origin, + authorization_endpoint: `${origin}/api/mcp/oauth/authorize`, + token_endpoint: `${origin}/api/mcp/oauth/token`, + registration_endpoint: `${origin}/api/mcp/oauth/register`, + scopes_supported: [...SUPPORTED_SCOPES], + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + // Public clients only — PKCE is the proof of possession, not a secret. + token_endpoint_auth_methods_supported: ["none"], + // S256 ONLY — `plain` is structurally unsupported (RFC 7636 + connectors). + code_challenge_methods_supported: ["S256"], + // CIMD is the preferred registration path (SEP-991); DCR is the fallback. + client_id_metadata_document_supported: true, + }; +} + +/** Build the `WWW-Authenticate` value pointing a 401 at the PRM (RFC 9728). */ +export function wwwAuthenticateChallenge( + requestUrl?: string, + scope?: string, +): string { + const origin = resolveBaseOrigin(requestUrl); + const prm = `${origin}/.well-known/oauth-protected-resource`; + const parts = ['Bearer realm="healthlog-mcp"', `resource_metadata="${prm}"`]; + if (scope) parts.push(`scope="${scope}"`); + return parts.join(", "); +} diff --git a/src/lib/mcp/oauth/pkce.ts b/src/lib/mcp/oauth/pkce.ts new file mode 100644 index 000000000..aaf8156a0 --- /dev/null +++ b/src/lib/mcp/oauth/pkce.ts @@ -0,0 +1,56 @@ +/** + * PKCE (RFC 7636) — S256 only. + * + * Both Claude.ai and ChatGPT send `code_challenge_method=S256` on every + * authorization request, and the AS advertises ONLY `S256` in + * `code_challenge_methods_supported`. The `plain` method is structurally + * unsupported here — a missing or non-S256 challenge is rejected at `/authorize`, + * and a verifier that does not hash to the bound challenge is rejected at + * `/token`. PKCE is therefore mandatory on the whole authorization-code flow. + */ +import { createHash, timingSafeEqual } from "node:crypto"; + +/** Base64url-encode without padding (the PKCE wire form). */ +function b64url(buf: Buffer): string { + return buf + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** The S256 transform: BASE64URL(SHA256(ASCII(verifier))). */ +export function s256Challenge(verifier: string): string { + return b64url(createHash("sha256").update(verifier, "ascii").digest()); +} + +/** RFC 7636 §4.1 — verifier is 43–128 chars of the unreserved set. */ +export function isValidVerifier(verifier: unknown): verifier is string { + return ( + typeof verifier === "string" && /^[A-Za-z0-9\-._~]{43,128}$/.test(verifier) + ); +} + +/** A non-empty challenge string (the exact length is the client's concern). */ +export function isValidChallenge(challenge: unknown): challenge is string { + return ( + typeof challenge === "string" && + challenge.length >= 43 && + challenge.length <= 128 + ); +} + +/** + * Constant-time check that `verifier` hashes (S256) to `challenge`. Returns + * false for any malformed input rather than throwing. + */ +export function verifyPkceS256(verifier: string, challenge: string): boolean { + if (!isValidVerifier(verifier) || !isValidChallenge(challenge)) { + return false; + } + const computed = Buffer.from(s256Challenge(verifier)); + const expected = Buffer.from(challenge); + return ( + computed.length === expected.length && timingSafeEqual(computed, expected) + ); +} diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts index f9dd8b9be..9e0ca7b4e 100644 --- a/src/lib/observability/redact-payload.ts +++ b/src/lib/observability/redact-payload.ts @@ -81,6 +81,11 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [ /telegram[_-]?chat[_-]?id/i, /chatid/i, /chat[_-]id/i, + // v1.22.0 — the PKCE `code_verifier` (the proof-of-possession secret the MCP + // OAuth token endpoint receives). `access_token` / `refresh_token` / + // `client_secret` are already covered by `/token/i` + `/secret/i`; the + // verifier is the one secret-shaped key those patterns miss. + /verifier/i, ]; const REDACTED = "[redacted]"; diff --git a/src/proxy.ts b/src/proxy.ts index 0061456bb..d686583e4 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -71,6 +71,13 @@ const PUBLIC_PATHS = [ */ const WELL_KNOWN_PUBLIC_PATHS = new Set([ "/.well-known/apple-app-site-association", + // v1.22.0 — OAuth discovery for the remote MCP connector. A remote client + // (Claude.ai / ChatGPT) reads these unauthenticated to bootstrap the OAuth + // 2.1 + PKCE flow from the pasted `/mcp` URL alone: RFC 9728 Protected + // Resource Metadata + RFC 8414 Authorization Server Metadata. Both are + // public, deterministic, secret-free discovery documents. + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-authorization-server", ]); /**