diff --git a/.gitignore b/.gitignore index ac30dea..0a76660 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,9 @@ node_modules/ dist/ .env .env.local +.dev.vars *.tsbuildinfo .sentry-build/ -.dev.vars .wrangler/ # Sentry Config File diff --git a/CLAUDE.md b/CLAUDE.md index d890b90..788eae1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,9 @@ MCP server for Plausible Analytics — wraps the Plausible Stats API v2 (`POST / Two entry points: - **STDIO** (`src/index.ts`) — local use, reads `PLAUSIBLE_API_KEY` from env -- **Cloudflare Worker** (`src/worker.ts`) — multi-tenant remote, each user passes their own API key via `Authorization: Bearer` header +- **Cloudflare Worker** (`src/worker.ts`) — remote, with two endpoints: + - `/mcp` — bring-your-own-key: each user passes their own Plausible API key via `Authorization: Bearer` (header clients like Claude Code/Cursor). + - `/internal` — OAuth 2.1 server (via `@cloudflare/workers-oauth-provider`) for managed connectors (Cowork/Claude.ai). Federates login to Cloudflare Access as an upstream OIDC provider (`src/access-handler.ts` + vendored `src/workers-oauth-utils.ts`), gates on `@sentry.io`, and queries a shared server-side `PLAUSIBLE_API_KEY`. The Access id_token is verified by reusing `src/cf-access.ts`. ## Commands @@ -46,12 +48,24 @@ Shared Zod schemas and filter builders live in `src/schemas.ts`. Plausible filte ## Testing -Tests use Vitest with mocked `fetch` — no Plausible account needed. Test helpers are in `__tests__/tools/_helpers.ts` (`createMockClient`, `getToolHandler`). The `worker.ts` entry point is excluded from `tsconfig.json` (it uses Cloudflare-specific types). +Tests use Vitest with mocked `fetch` — no Plausible account needed. Test helpers are in `__tests__/tools/_helpers.ts` (`createMockClient`, `getToolHandler`). The Cloudflare-specific worker files (`worker.ts`, `env.ts`, `access-handler.ts`, `workers-oauth-utils.ts`) are excluded from the default `tsconfig.json` (they use Cloudflare/Workers globals, not Node), and are type-checked separately via `pnpm typecheck` (`tsconfig.worker.json`, which swaps in `@cloudflare/workers-types`). ## Environment Variables | Variable | Required | Description | |----------|----------|-------------| -| `PLAUSIBLE_API_KEY` | Yes (STDIO only) | Plausible API key | +| `PLAUSIBLE_API_KEY` | Yes (STDIO; also Worker `/internal`) | Plausible API key. On the Worker it's the shared key used by the OAuth `/internal` endpoint (the `/mcp` BYOK endpoint takes the user's key via Bearer). | | `PLAUSIBLE_BASE_URL` | No | Custom Plausible instance URL (default: `https://plausible.io`) | | `PLAUSIBLE_DEFAULT_SITE_ID` | No | Default site domain to avoid passing `site_id` every call | + +Worker `/internal` (OAuth 2.1 via Cloudflare Access) also needs these secrets — see README "Setting up the `/internal` OAuth endpoint": + +| Variable | Description | +|----------|-------------| +| `CF_ACCESS_TEAM_DOMAIN` | `https://.cloudflareaccess.com` — verifies the Access id_token JWKS + issuer | +| `ACCESS_CLIENT_ID` / `ACCESS_CLIENT_SECRET` | Credentials from the Access SaaS/OIDC app | +| `ACCESS_AUTHORIZATION_URL` / `ACCESS_TOKEN_URL` | Access OIDC authorize/token endpoints (upstream) | +| `COOKIE_ENCRYPTION_KEY` | `openssl rand -hex 32` — signs approval/CSRF cookies | +| `OAUTH_KV` (binding) | KV namespace for OAuth tokens/grants/state | +| `SERVICE_HOSTNAME` (var, optional) | Pins the accepted Host on `/authorize` + `/callback`; skipped when unset | +| `ALLOWED_EMAIL_DOMAIN` (var, optional) | Comma-separated email domain(s) gating `/internal` login. Defaults to `sentry.io`; the `@sentry.io` gate is **not** hardcoded — self-hosters set their own domain | diff --git a/README.md b/README.md index 3ee19c7..00a8115 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,12 @@ All tools are **read-only** and annotated with `readOnlyHint: true`. ### Remote (Hosted) -A hosted instance is available at **`https://plausible-mcp.sentry.dev`**. Each user provides their own Plausible API key as a Bearer token — no setup required. +A hosted instance is available at **`https://plausible-mcp.sentry.dev`**. -Add to Claude Code: +**With your own Plausible API key** (any user): ```bash -claude mcp add --transport http plausible https://plausible-mcp.sentry.dev/mcp --header "Authorization: Bearer YOUR_PLAUSIBLE_API_KEY" +claude mcp add plausible --transport http --header "Authorization: Bearer YOUR_PLAUSIBLE_API_KEY" https://plausible-mcp.sentry.dev/mcp ``` Or add manually to your MCP client config (Claude Desktop, Cursor, etc.): @@ -45,6 +45,18 @@ Or add manually to your MCP client config (Claude Desktop, Cursor, etc.): } ``` +**Sentry employees** (via OAuth 2.1 + Cloudflare Access): + +The `/internal` endpoint is an OAuth 2.1 server — no API key needed. Add it as a remote/custom connector in any OAuth-capable MCP client (Cowork, Claude.ai connectors, Claude Desktop): + +``` +https://plausible-mcp.sentry.dev/internal +``` + +The client discovers the OAuth endpoints automatically, sends you through Sentry SSO (Cloudflare Access), and only `@sentry.io` identities are granted access. Queries run against a shared, server-side Plausible API key — you never handle a key. + +> The **hosted** `/internal` at `plausible-mcp.sentry.dev` is Sentry-only and can't be used outside the org. To run `/internal` for a different organization, [self-host](#self-hosting-cloudflare-workers) and set `ALLOWED_EMAIL_DOMAIN` to your own domain. (The public `/mcp` bring-your-own-key endpoint has no such restriction.) + ### Local (STDIO) If you prefer to run it locally: @@ -89,7 +101,36 @@ pnpm install npx wrangler deploy ``` -The worker is multi-tenant — each user passes their own Plausible API key via the `Authorization: Bearer` header. No shared secrets needed on the server. +The worker exposes two endpoints: + +- **`/mcp`** — bring-your-own-key. Each user passes their own Plausible API key via the `Authorization: Bearer` header. No shared secrets needed on the server. Works with any header-capable MCP client (Claude Code, Cursor, MCP Inspector). +- **`/internal`** — OAuth 2.1 server for managed connectors (Cowork, Claude.ai). Federates login to Cloudflare Access and queries a shared, server-side Plausible API key. Access is gated to the email domain(s) in `ALLOWED_EMAIL_DOMAIN` (defaults to `sentry.io`) — **not** tied to Sentry when you self-host; set it to your own domain. + +#### Setting up the `/internal` OAuth endpoint + +1. **Create the KV namespace** (stores OAuth tokens/grants/state) and paste the id into `wrangler.toml`: + ```bash + npx wrangler kv namespace create OAUTH_KV + ``` +2. **Register a Cloudflare Access SaaS app** (Zero Trust → Access → Applications → *Add an application* → *SaaS* → **OIDC**): + - **Redirect URL**: `https:///callback` + - Scopes: `openid`, `email`, `profile` + - Add an Access **policy** restricting to your email domain (e.g. `@acme.com`) and your identity provider (Google SSO). + - Copy the **Client ID**, **Client secret**, **Authorization endpoint**, and **Token endpoint**. +3. **Set the worker secrets**: + ```bash + npx wrangler secret put CF_ACCESS_TEAM_DOMAIN # https://.cloudflareaccess.com + npx wrangler secret put ACCESS_CLIENT_ID + npx wrangler secret put ACCESS_CLIENT_SECRET + npx wrangler secret put ACCESS_AUTHORIZATION_URL + npx wrangler secret put ACCESS_TOKEN_URL + npx wrangler secret put COOKIE_ENCRYPTION_KEY # openssl rand -hex 32 + npx wrangler secret put PLAUSIBLE_API_KEY # shared key for /internal queries + ``` +4. **Set the `[vars]` in `wrangler.toml`**: + - `SERVICE_HOSTNAME` — your worker host (default `plausible-mcp.sentry.dev`). Pins the accepted Host on the OAuth authorize/callback endpoints; remove it to disable that check. + - `ALLOWED_EMAIL_DOMAIN` — the email domain(s) allowed to sign in, comma-separated, `@` optional (default `sentry.io`). This is enforced in code **in addition to** the Access policy in step 2, so you must set it to your own domain — otherwise every login is rejected. +5. **Deploy** (`npx wrangler deploy`), then point an OAuth MCP client at `https:///internal`. ## Configuration @@ -98,8 +139,10 @@ The worker is multi-tenant — each user passes their own Plausible API key via | `PLAUSIBLE_API_KEY` | Yes (STDIO) | — | Your Plausible API key ([get one here](https://plausible.io/docs/stats-api)) | | `PLAUSIBLE_BASE_URL` | No | `https://plausible.io` | URL of your Plausible instance (for self-hosted) | | `PLAUSIBLE_DEFAULT_SITE_ID` | No | — | Default site domain so you don't have to pass `site_id` every call | +| `SERVICE_HOSTNAME` | No (Worker) | — | Public host to pin the OAuth authorize/callback endpoints to (defense in depth). Skipped when unset. | +| `ALLOWED_EMAIL_DOMAIN` | No (Worker `/internal`) | `sentry.io` | Comma-separated email domain(s) allowed to sign in to `/internal`. Set to your own domain when self-hosting. | -For the Cloudflare Worker, `PLAUSIBLE_API_KEY` is not needed as an env var — each user passes their own key via the `Authorization: Bearer` header. +On the Worker, the `/mcp` endpoint needs no server-side key — each user passes their own via `Authorization: Bearer`. The `/internal` OAuth endpoint uses a shared server-side `PLAUSIBLE_API_KEY` secret (see [self-hosting](#setting-up-the-internal-oauth-endpoint)). ## Plausible API diff --git a/__tests__/access-handler.test.ts b/__tests__/access-handler.test.ts new file mode 100644 index 0000000..a68a9cf --- /dev/null +++ b/__tests__/access-handler.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, vi } from "vitest"; +import { handleAccessRequest } from "../src/access-handler.js"; +import type { Env } from "../src/env.js"; + +const HOST = "https://plausible-mcp.sentry.dev"; +const COOKIE_KEY = "test-cookie-encryption-key-0123456789abcdef"; +const CSRF = "csrf-token-123"; + +const CLIENT_ID = "client-1"; +const REGISTERED_REDIRECT = "https://claude.ai/api/mcp/auth_callback"; + +function makeEnv(overrides: Partial = {}): Env { + return { + SERVICE_HOSTNAME: "plausible-mcp.sentry.dev", + COOKIE_ENCRYPTION_KEY: COOKIE_KEY, + ACCESS_CLIENT_ID: "access-client-id", + ACCESS_CLIENT_SECRET: "access-client-secret", + ACCESS_AUTHORIZATION_URL: "https://team.cloudflareaccess.com/cdn-cgi/access/sso/oidc/authorize", + ACCESS_TOKEN_URL: "https://team.cloudflareaccess.com/cdn-cgi/access/token", + CF_ACCESS_TEAM_DOMAIN: "https://team.cloudflareaccess.com", + OAUTH_KV: { put: vi.fn().mockResolvedValue(undefined) } as unknown as KVNamespace, + OAUTH_PROVIDER: { + lookupClient: vi.fn().mockResolvedValue({ + clientId: CLIENT_ID, + redirectUris: [REGISTERED_REDIRECT], + }), + } as unknown as Env["OAUTH_PROVIDER"], + ...overrides, + } as Env; +} + +function postAuthorize(redirectUri: string, host = HOST): Request { + const oauthReqInfo = { + clientId: CLIENT_ID, + redirectUri, + responseType: "code", + scope: ["mcp"], + state: "client-state", + codeChallenge: "challenge", + codeChallengeMethod: "S256", + }; + const encodedState = btoa(JSON.stringify({ oauthReqInfo })); + const body = new URLSearchParams({ csrf_token: CSRF, state: encodedState }); + return new Request(`${host}/authorize`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Cookie: `__Host-CSRF_TOKEN=${CSRF}`, + }, + body: body.toString(), + }); +} + +const ctx = {} as ExecutionContext; + +describe("handleAccessRequest — host guard", () => { + it("rejects an unexpected host when SERVICE_HOSTNAME is configured", async () => { + const res = await handleAccessRequest( + new Request("https://evil.example/authorize", { method: "GET" }), + makeEnv(), + ctx, + ); + expect(res.status).toBe(400); + expect(await res.text()).toBe("Unexpected host"); + }); + + it("allows the configured SERVICE_HOSTNAME", async () => { + const res = await handleAccessRequest( + new Request(`${HOST}/unknown-path`, { method: "GET" }), + makeEnv(), + ctx, + ); + // Passes the host guard; falls through to the 404 for unknown routes. + expect(res.status).toBe(404); + }); + + it("allows localhost for wrangler dev", async () => { + const res = await handleAccessRequest( + new Request("http://localhost:8787/unknown-path", { method: "GET" }), + makeEnv(), + ctx, + ); + expect(res.status).toBe(404); + }); + + it("skips the host check when SERVICE_HOSTNAME is unset (self-hosting)", async () => { + const res = await handleAccessRequest( + new Request("https://my-own-worker.example.com/unknown-path", { method: "GET" }), + makeEnv({ SERVICE_HOSTNAME: undefined }), + ctx, + ); + expect(res.status).toBe(404); + }); +}); + +describe("handleAccessRequest — GET /authorize", () => { + it("returns 400 (not an uncaught 500) when the approval-dialog state can't be encoded", async () => { + const env = makeEnv({ + OAUTH_PROVIDER: { + // Client-supplied `state` with a non-Latin1 char makes btoa throw in the dialog. + parseAuthRequest: vi.fn().mockResolvedValue({ + clientId: CLIENT_ID, + redirectUri: REGISTERED_REDIRECT, + responseType: "code", + scope: ["mcp"], + state: "emoji-\u{1F600}", + }), + lookupClient: vi.fn().mockResolvedValue({ + clientId: CLIENT_ID, + redirectUris: [REGISTERED_REDIRECT], + }), + } as unknown as Env["OAUTH_PROVIDER"], + }); + + // No approved-client cookie → falls through to rendering the consent dialog. + const res = await handleAccessRequest( + new Request(`${HOST}/authorize?client_id=${CLIENT_ID}`, { method: "GET" }), + env, + ctx, + ); + expect(res.status).toBe(400); + }); +}); + +describe("handleAccessRequest — POST /authorize redirect_uri validation", () => { + it("rejects a redirect_uri not registered to the client (tampered state)", async () => { + const env = makeEnv(); + const res = await handleAccessRequest( + postAuthorize("https://attacker.example/steal"), + env, + ctx, + ); + + expect(res.status).toBe(400); + expect(await res.text()).toBe("Invalid redirect_uri"); + // Must reject before persisting any OAuth state. + expect(env.OAUTH_KV.put).not.toHaveBeenCalled(); + }); + + it("rejects when the client is unknown", async () => { + const env = makeEnv({ + OAUTH_PROVIDER: { + lookupClient: vi.fn().mockResolvedValue(null), + } as unknown as Env["OAUTH_PROVIDER"], + }); + const res = await handleAccessRequest( + postAuthorize(REGISTERED_REDIRECT), + env, + ctx, + ); + + expect(res.status).toBe(400); + expect(await res.text()).toBe("Invalid request"); + expect(env.OAUTH_KV.put).not.toHaveBeenCalled(); + }); + + it("accepts a registered redirect_uri and redirects to Cloudflare Access", async () => { + const env = makeEnv(); + const res = await handleAccessRequest( + postAuthorize(REGISTERED_REDIRECT), + env, + ctx, + ); + + expect(res.status).toBe(302); + const location = res.headers.get("location") ?? ""; + expect(location).toContain( + "https://team.cloudflareaccess.com/cdn-cgi/access/sso/oidc/authorize", + ); + // The redirect_uri sent upstream is our own /callback on the pinned host. + const upstream = new URL(location); + expect(upstream.searchParams.get("redirect_uri")).toBe(`${HOST}/callback`); + expect(upstream.searchParams.get("code_challenge_method")).toBe("S256"); + // State was persisted for the callback to validate. + expect(env.OAUTH_KV.put).toHaveBeenCalledOnce(); + }); + + it("rejects a mismatched CSRF token before touching client lookup", async () => { + const env = makeEnv(); + const req = new Request(`${HOST}/authorize`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Cookie: `__Host-CSRF_TOKEN=different-token`, + }, + body: new URLSearchParams({ + csrf_token: CSRF, + state: btoa(JSON.stringify({ oauthReqInfo: { clientId: CLIENT_ID } })), + }).toString(), + }); + + const res = await handleAccessRequest(req, env, ctx); + expect(res.status).toBe(400); + expect(env.OAUTH_PROVIDER.lookupClient).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/worker.test.ts b/__tests__/worker.test.ts new file mode 100644 index 0000000..3d26500 --- /dev/null +++ b/__tests__/worker.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + verifyCloudflareAccessJwt, + clearCertsCache, + parseAllowedEmailDomains, + type AccessConfig, +} from "../src/cf-access.js"; + +const TEAM_DOMAIN = "https://sentry.cloudflareaccess.com"; +const AUD = "test-audience-tag"; + +const config: AccessConfig = { + teamDomain: TEAM_DOMAIN, + aud: AUD, + allowedEmailDomains: ["sentry.io"], +}; + +function base64Url(obj: Record): string { + return btoa(JSON.stringify(obj)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +async function generateKeyPair() { + return crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); +} + +async function signJwt( + header: Record, + payload: Record, + privateKey: CryptoKey, +): Promise { + const headerB64 = base64Url(header); + const payloadB64 = base64Url(payload); + const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`); + const sig = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, data); + const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return `${headerB64}.${payloadB64}.${sigB64}`; +} + +async function makeValidJwt(overrides: { + header?: Record; + payload?: Record; + kid?: string; +} = {}) { + const keyPair = await generateKeyPair(); + const kid = overrides.kid ?? "test-kid"; + const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); + (jwk as Record).kid = kid; + + const header = { alg: "RS256", kid, ...overrides.header }; + const payload = { + email: "user@sentry.io", + aud: [AUD], + iss: TEAM_DOMAIN, + exp: Math.floor(Date.now() / 1000) + 3600, + iat: Math.floor(Date.now() / 1000), + ...overrides.payload, + }; + + const jwt = await signJwt(header, payload, keyPair.privateKey); + return { jwt, jwk, keyPair }; +} + +function mockCertsEndpoint(jwk: JsonWebKey) { + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ keys: [jwk] }), { status: 200 })), + ); +} + +describe("verifyCloudflareAccessJwt", () => { + beforeEach(() => { + vi.restoreAllMocks(); + clearCertsCache(); + }); + + it("returns null for malformed JWT", async () => { + expect(await verifyCloudflareAccessJwt("not.a.valid.jwt.token", config)).toBeNull(); + expect(await verifyCloudflareAccessJwt("", config)).toBeNull(); + expect(await verifyCloudflareAccessJwt("onepart", config)).toBeNull(); + }); + + it("fails closed (returns null, does not throw) when a segment isn't valid base64/JSON", async () => { + const { jwk } = await makeValidJwt(); + mockCertsEndpoint(jwk); + + // Three parts (so it passes the length check and reaches segment decoding), but the + // header/payload aren't valid base64url-encoded JSON — atob/JSON.parse would throw. + const notJson = btoa("not json {").replace(/=+$/, ""); + const invalidBase64 = "@@@"; + + await expect( + verifyCloudflareAccessJwt(`${notJson}.${notJson}.${notJson}`, config), + ).resolves.toBeNull(); + await expect( + verifyCloudflareAccessJwt(`${invalidBase64}.${invalidBase64}.${invalidBase64}`, config), + ).resolves.toBeNull(); + }); + + it("fails closed (returns null) when the certs fetch itself throws", async () => { + const { jwt } = await makeValidJwt(); + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.reject(new Error("network down")), + ); + + await expect(verifyCloudflareAccessJwt(jwt, config)).resolves.toBeNull(); + }); + + it("returns email for a valid JWT", async () => { + const { jwt, jwk } = await makeValidJwt(); + mockCertsEndpoint(jwk); + + const result = await verifyCloudflareAccessJwt(jwt, config); + expect(result).toEqual({ email: "user@sentry.io" }); + }); + + it("returns null for expired JWT", async () => { + const { jwt, jwk } = await makeValidJwt({ + payload: { exp: Math.floor(Date.now() / 1000) - 60 }, + }); + mockCertsEndpoint(jwk); + + expect(await verifyCloudflareAccessJwt(jwt, config)).toBeNull(); + }); + + it("returns null for wrong audience", async () => { + const { jwt, jwk } = await makeValidJwt({ + payload: { aud: ["wrong-audience"] }, + }); + mockCertsEndpoint(jwk); + + expect(await verifyCloudflareAccessJwt(jwt, config)).toBeNull(); + }); + + it("handles aud as a string (not array)", async () => { + const { jwt, jwk } = await makeValidJwt({ + payload: { aud: AUD }, + }); + mockCertsEndpoint(jwk); + + expect(await verifyCloudflareAccessJwt(jwt, config)).toEqual({ email: "user@sentry.io" }); + }); + + it("returns null for non-sentry.io email", async () => { + const { jwt, jwk } = await makeValidJwt({ + payload: { email: "hacker@evil.com" }, + }); + mockCertsEndpoint(jwk); + + expect(await verifyCloudflareAccessJwt(jwt, config)).toBeNull(); + }); + + it("accepts an email in a configured custom domain (self-hosting)", async () => { + const { jwt, jwk } = await makeValidJwt({ + payload: { email: "user@acme.com" }, + }); + mockCertsEndpoint(jwk); + + const customConfig: AccessConfig = { + ...config, + allowedEmailDomains: ["acme.com", "contractors.acme.com"], + }; + expect(await verifyCloudflareAccessJwt(jwt, customConfig)).toEqual({ + email: "user@acme.com", + }); + // The default sentry.io gate must NOT admit the custom-domain user. + expect(await verifyCloudflareAccessJwt(jwt, config)).toBeNull(); + }); + + it("returns null for wrong issuer", async () => { + const { jwt, jwk } = await makeValidJwt({ + payload: { iss: "https://evil.cloudflareaccess.com" }, + }); + mockCertsEndpoint(jwk); + + expect(await verifyCloudflareAccessJwt(jwt, config)).toBeNull(); + }); + + it("returns null when kid doesn't match any cert even after refresh", async () => { + const { jwt, jwk } = await makeValidJwt({ kid: "unknown-kid" }); + (jwk as Record).kid = "different-kid"; + mockCertsEndpoint(jwk); + + expect(await verifyCloudflareAccessJwt(jwt, config)).toBeNull(); + }); + + it("refreshes certs on kid miss and succeeds with rotated key", async () => { + const { jwt, jwk } = await makeValidJwt(); + const staleJwk = { ...jwk, kid: "old-kid" } as JsonWebKey; + let callCount = 0; + vi.spyOn(globalThis, "fetch").mockImplementation(() => { + callCount++; + const keys = callCount === 1 ? [staleJwk] : [jwk]; + return Promise.resolve(new Response(JSON.stringify({ keys }), { status: 200 })); + }); + + const result = await verifyCloudflareAccessJwt(jwt, config); + expect(result).toEqual({ email: "user@sentry.io" }); + }); + + it("returns null when signature is invalid", async () => { + const { jwt } = await makeValidJwt(); + const otherKeyPair = await generateKeyPair(); + const otherJwk = await crypto.subtle.exportKey("jwk", otherKeyPair.publicKey); + (otherJwk as Record).kid = "test-kid"; + mockCertsEndpoint(otherJwk); + + expect(await verifyCloudflareAccessJwt(jwt, config)).toBeNull(); + }); + + it("returns null when certs endpoint fails", async () => { + const { jwt } = await makeValidJwt(); + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response("Internal Server Error", { status: 500 })), + ); + + expect(await verifyCloudflareAccessJwt(jwt, config)).toBeNull(); + }); + + it("returns null when certs response has no keys array", async () => { + const { jwt } = await makeValidJwt(); + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({}), { status: 200 })), + ); + + expect(await verifyCloudflareAccessJwt(jwt, config)).toBeNull(); + }); + + it("returns null when certs response has empty keys array", async () => { + const { jwt } = await makeValidJwt(); + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ keys: [] }), { status: 200 })), + ); + + expect(await verifyCloudflareAccessJwt(jwt, config)).toBeNull(); + }); + + it("returns null when certs response keys is not an array", async () => { + const { jwt } = await makeValidJwt(); + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ keys: "not-an-array" }), { status: 200 })), + ); + + expect(await verifyCloudflareAccessJwt(jwt, config)).toBeNull(); + }); + + it("parseAllowedEmailDomains normalizes and defaults", () => { + expect(parseAllowedEmailDomains(undefined)).toEqual(["sentry.io"]); + expect(parseAllowedEmailDomains("")).toEqual(["sentry.io"]); + expect(parseAllowedEmailDomains(" ")).toEqual(["sentry.io"]); + expect(parseAllowedEmailDomains("@acme.com")).toEqual(["acme.com"]); + expect(parseAllowedEmailDomains("Acme.com, @Contractors.Acme.com")).toEqual([ + "acme.com", + "contractors.acme.com", + ]); + }); + + it("caches certs across calls", async () => { + const { jwt, jwk } = await makeValidJwt(); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ keys: [jwk] }), { status: 200 })), + ); + + await verifyCloudflareAccessJwt(jwt, config); + await verifyCloudflareAccessJwt(jwt, config); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/package.json b/package.json index 535bdae..c437bf8 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "packageManager": "pnpm@11.1.3", "scripts": { "build": "tsc", + "typecheck": "tsc -p tsconfig.worker.json", "dev": "tsx src/index.ts", "start": "node dist/index.js", "test": "vitest run", @@ -26,8 +27,9 @@ ], "license": "MIT", "dependencies": { + "@cloudflare/workers-oauth-provider": "^0.4.0", "@cloudflare/workers-types": "^4.20260317.1", - "@modelcontextprotocol/sdk": "^1.7.0", + "@modelcontextprotocol/sdk": "1.28.0", "@sentry/cloudflare": "^10.45.0", "agents": "^0.8.0", "zod": "^3.24.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4734bfb..da71483 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,15 @@ importers: .: dependencies: + '@cloudflare/workers-oauth-provider': + specifier: ^0.4.0 + version: 0.4.0 '@cloudflare/workers-types': specifier: ^4.20260317.1 version: 4.20260611.1 '@modelcontextprotocol/sdk': - specifier: ^1.7.0 - version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) + specifier: 1.28.0 + version: 1.28.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) '@sentry/cloudflare': specifier: ^10.45.0 version: 10.57.0(@cloudflare/workers-types@4.20260611.1) @@ -193,6 +196,9 @@ packages: '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@cloudflare/workers-oauth-provider@0.4.0': + resolution: {integrity: sha512-UtbV8hjC2NloB+Ds6J6v/9HiG8rx8MbdeYGCyFwOACT5vANWzDL6SKo3W5UZymsXiameAgC7jAmtUx4cc+Qpaw==} + '@cloudflare/workers-types@4.20260611.1': resolution: {integrity: sha512-DLiz8Ol1OIWLigJ+dGvuQ5Nm66D/CHNPasl8YnPiz6fGo10ggYSIVuEDMlFk6oho+piAHstNmZMl088w8xqW6g==} @@ -396,16 +402,6 @@ packages: '@cfworker/json-schema': optional: true - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} - engines: {node: '>=18'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true - '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -1899,6 +1895,8 @@ snapshots: '@cfworker/json-schema@4.1.1': {} + '@cloudflare/workers-oauth-provider@0.4.0': {} + '@cloudflare/workers-types@4.20260611.1': {} '@emnapi/core@1.11.0': @@ -2044,30 +2042,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76)': - dependencies: - '@hono/node-server': 1.19.14(hono@4.12.25) - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.1.0 - express: 5.2.1 - express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.25 - jose: 6.2.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) - optionalDependencies: - '@cfworker/json-schema': 4.1.1 - transitivePeerDependencies: - - supports-color - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': dependencies: '@emnapi/core': 1.11.0 diff --git a/src/access-handler.ts b/src/access-handler.ts new file mode 100644 index 0000000..55f5fcf --- /dev/null +++ b/src/access-handler.ts @@ -0,0 +1,254 @@ +import type { AuthRequest } from "@cloudflare/workers-oauth-provider"; +import { parseAllowedEmailDomains, verifyCloudflareAccessJwt } from "./cf-access.js"; +import type { Env, Props } from "./env.js"; +import { + addApprovedClient, + createOAuthState, + fetchUpstreamAuthToken, + generateCSRFProtection, + getUpstreamAuthorizeUrl, + isClientApproved, + OAuthError, + renderApprovalDialog, + validateCSRFToken, + validateOAuthState, +} from "./workers-oauth-utils.js"; + +const SERVER_INFO = { + name: "Plausible Analytics MCP", + description: + "Query Plausible Analytics traffic and conversion data. Sentry SSO (Cloudflare Access) is required to connect.", +}; + +// The OAuth federation endpoints derive the /callback redirect_uri from request.url +// (see redirectToAccess + fetchUpstreamAuthToken), which is attacker-influenced input. +// When SERVICE_HOSTNAME is configured we pin the accepted Host to it (plus localhost for +// `wrangler dev`) as defense in depth against redirect_uri pointing at an unexpected +// origin if the route set is ever widened. When it's unset the check is skipped so +// self-hosted deploys on any hostname still work (see README). +function isAllowedHost(hostname: string, env: Env): boolean { + if (hostname === "localhost" || hostname === "127.0.0.1") return true; + if (!env.SERVICE_HOSTNAME) return true; + return hostname === env.SERVICE_HOSTNAME; +} + +/** + * Implements the MCP client-facing OAuth authorize/callback endpoints, federating + * the actual login to Cloudflare Access (configured as an upstream OIDC provider). + * + * Flow: + * 1. MCP client → GET /authorize → (consent) → redirect to Access /authorize + * 2. Access → GET /callback?code → exchange code, verify id_token, gate on @sentry.io + * 3. completeAuthorization → mint our own token → redirect back to the MCP client + * + * Token + grant storage, discovery metadata, /token and /register are handled by + * @cloudflare/workers-oauth-provider; this only owns /authorize and /callback. + */ +export async function handleAccessRequest( + request: Request, + env: Env, + _ctx: ExecutionContext, +): Promise { + const url = new URL(request.url); + const { pathname, searchParams } = url; + + // Reject unexpected Hosts before deriving any redirect_uri from request.url. + if (!isAllowedHost(url.hostname, env)) { + return new Response("Unexpected host", { status: 400 }); + } + + if (request.method === "GET" && pathname === "/authorize") { + const oauthReqInfo = await env.OAUTH_PROVIDER.parseAuthRequest(request); + const { clientId } = oauthReqInfo; + if (!clientId) { + return new Response("Invalid request", { status: 400 }); + } + + // Skip the consent screen for clients the user already approved on this device. + if (await isClientApproved(request, clientId, env.COOKIE_ENCRYPTION_KEY)) { + const { stateToken, codeChallenge } = await createOAuthState( + oauthReqInfo, + env.OAUTH_KV, + env.COOKIE_ENCRYPTION_KEY, + ); + return redirectToAccess(request, env, stateToken, codeChallenge); + } + + const client = await env.OAUTH_PROVIDER.lookupClient(clientId); + const { token: csrfToken, setCookie } = generateCSRFProtection(); + try { + // renderApprovalDialog base64-encodes the request state; btoa throws a DOMException + // on non-ASCII characters (the client-supplied `state`/redirect params are arbitrary). + // It's pure/no-I/O, so a throw here is bad input — return 400, not an uncaught 500. + return renderApprovalDialog(request, { + client, + csrfToken, + server: SERVER_INFO, + setCookie, + state: { oauthReqInfo }, + }); + } catch { + return new Response("Invalid request", { status: 400 }); + } + } + + if (request.method === "POST" && pathname === "/authorize") { + try { + const formData = await request.formData(); + const csrfResult = validateCSRFToken(formData, request); + + const encodedState = formData.get("state"); + if (!encodedState || typeof encodedState !== "string") { + return new Response("Missing state in form data", { status: 400 }); + } + + let state: { oauthReqInfo?: AuthRequest }; + try { + state = JSON.parse(atob(encodedState)); + } catch (_e) { + return new Response("Invalid state data", { status: 400 }); + } + + if (!state.oauthReqInfo || !state.oauthReqInfo.clientId) { + return new Response("Invalid request", { status: 400 }); + } + + // The oauthReqInfo here is round-tripped through an attacker-influenceable hidden + // form field, so don't trust its redirect_uri. Re-validate it against the client's + // registered redirect URIs before it's persisted and later used to send the auth + // code back — otherwise a tampered field could exfiltrate the code to another origin. + const client = await env.OAUTH_PROVIDER.lookupClient( + state.oauthReqInfo.clientId, + ); + if (!client) { + return new Response("Invalid request", { status: 400 }); + } + if ( + !state.oauthReqInfo.redirectUri || + !client.redirectUris?.includes(state.oauthReqInfo.redirectUri) + ) { + return new Response("Invalid redirect_uri", { status: 400 }); + } + + const approvedClientCookie = await addApprovedClient( + request, + state.oauthReqInfo.clientId, + env.COOKIE_ENCRYPTION_KEY, + ); + const { stateToken, codeChallenge } = await createOAuthState( + state.oauthReqInfo, + env.OAUTH_KV, + env.COOKIE_ENCRYPTION_KEY, + ); + + const redirectHeaders = new Headers(); + redirectHeaders.append("Set-Cookie", approvedClientCookie); + redirectHeaders.append("Set-Cookie", csrfResult.clearCookie); + return redirectToAccess(request, env, stateToken, codeChallenge, redirectHeaders); + } catch (error) { + if (error instanceof OAuthError) { + return error.toResponse(); + } + return new Response("Internal server error", { status: 500 }); + } + } + + if (request.method === "GET" && pathname === "/callback") { + let oauthReqInfo: AuthRequest; + let codeVerifier: string; + try { + const result = await validateOAuthState( + request, + env.OAUTH_KV, + env.COOKIE_ENCRYPTION_KEY, + ); + oauthReqInfo = result.oauthReqInfo; + codeVerifier = result.codeVerifier; + } catch (error) { + if (error instanceof OAuthError) { + return error.toResponse(); + } + return new Response("Internal server error", { status: 500 }); + } + + if (!oauthReqInfo.clientId) { + return new Response("Invalid OAuth request data", { status: 400 }); + } + + const [, idToken, errResponse] = await fetchUpstreamAuthToken({ + client_id: env.ACCESS_CLIENT_ID, + client_secret: env.ACCESS_CLIENT_SECRET, + code: searchParams.get("code") ?? undefined, + code_verifier: codeVerifier, + redirect_uri: new URL("/callback", request.url).href, + upstream_url: env.ACCESS_TOKEN_URL, + }); + if (errResponse) { + return errResponse; + } + + // Verify the id_token: signed by the team's /cdn-cgi/access/certs, its `aud` is our + // OIDC client_id, its `iss` is the team domain, and the email must be in one of the + // allowed domains (defaults to sentry.io; configurable via ALLOWED_EMAIL_DOMAIN). + const allowedEmailDomains = parseAllowedEmailDomains(env.ALLOWED_EMAIL_DOMAIN); + const identity = await verifyCloudflareAccessJwt(idToken, { + teamDomain: env.CF_ACCESS_TEAM_DOMAIN, + aud: env.ACCESS_CLIENT_ID, + allowedEmailDomains, + }); + if (!identity) { + return new Response( + `Forbidden: a valid identity in an allowed domain (${allowedEmailDomains.join(", ")}) is required.`, + { status: 403 }, + ); + } + + // Keep plaintext PII out of the (unencrypted) grant store: the OAuth `userId` + // and `metadata` are persisted in KV in the clear, so derive a stable opaque + // id from the email instead. The real email lives only in `props`, which the + // provider encrypts, and is what we use for Sentry attribution. + const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({ + metadata: {}, // intentionally empty — no plaintext PII in the unencrypted grant + props: { email: identity.email } satisfies Props, + request: oauthReqInfo, + scope: oauthReqInfo.scope, + // Normalize casing so a mixed-case email from the IdP maps to one stable grant. + userId: await sha256Hex(identity.email.toLowerCase()), + }); + return Response.redirect(redirectTo, 302); + } + + return new Response("Not Found", { status: 404 }); +} + +async function sha256Hex(input: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(input), + ); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function redirectToAccess( + request: Request, + env: Env, + stateToken: string, + codeChallenge: string, + extraHeaders: Headers = new Headers(), +): Promise { + const headers = new Headers(extraHeaders); + headers.set( + "location", + getUpstreamAuthorizeUrl({ + client_id: env.ACCESS_CLIENT_ID, + code_challenge: codeChallenge, + redirect_uri: new URL("/callback", request.url).href, + scope: "openid email profile", + state: stateToken, + upstream_url: env.ACCESS_AUTHORIZATION_URL, + }), + ); + return new Response(null, { headers, status: 302 }); +} diff --git a/src/cf-access.ts b/src/cf-access.ts new file mode 100644 index 0000000..24f1115 --- /dev/null +++ b/src/cf-access.ts @@ -0,0 +1,150 @@ +function base64UrlDecode(input: string): string { + const padded = input.replace(/-/g, "+").replace(/_/g, "/") + + "=".repeat((4 - (input.length % 4)) % 4); + return atob(padded); +} + +interface AccessJwtPayload { + email?: string; + iss?: string; + aud?: string | string[]; + exp?: number; + iat?: number; +} + +export interface AccessConfig { + teamDomain: string; + aud: string; + /** + * Email domains allowed to authenticate, without the leading "@" (e.g. + * ["sentry.io"]). The verified identity's email must end with one of these. + * Defense in depth on top of the upstream Cloudflare Access policy. + */ + allowedEmailDomains: string[]; +} + +/** + * Parses the ALLOWED_EMAIL_DOMAIN env value (comma-separated, "@" optional) into a + * normalized list. Defaults to ["sentry.io"] when unset/empty so the canonical hosted + * deploy stays Sentry-only and self-hosters fail closed until they configure their own. + */ +export function parseAllowedEmailDomains(raw?: string): string[] { + const domains = (raw ?? "") + .split(",") + .map((d) => d.trim().toLowerCase().replace(/^@/, "")) + .filter((d) => d.length > 0); + return domains.length > 0 ? domains : ["sentry.io"]; +} + +let cachedCerts: { keys: JsonWebKey[]; fetchedAt: number } | null = null; +const CERTS_CACHE_TTL_MS = 5 * 60 * 1000; + +async function fetchCerts( + teamDomain: string, +): Promise<{ keys: JsonWebKey[]; fetchedAt: number } | null> { + const res = await fetch(`${teamDomain}/cdn-cgi/access/certs`); + if (!res.ok) return null; + + const body = (await res.json()) as { keys?: unknown }; + if (!Array.isArray(body.keys) || body.keys.length === 0) return null; + cachedCerts = { keys: body.keys as JsonWebKey[], fetchedAt: Date.now() }; + return cachedCerts; +} + +async function getAccessCerts( + teamDomain: string, + forceRefresh = false, +): Promise<{ keys: JsonWebKey[] } | null> { + if (!forceRefresh && cachedCerts && Date.now() - cachedCerts.fetchedAt < CERTS_CACHE_TTL_MS) { + return cachedCerts; + } + return await fetchCerts(teamDomain) ?? cachedCerts; +} + +export function clearCertsCache(): void { + cachedCerts = null; +} + +export async function verifyCloudflareAccessJwt( + jwt: string, + config: AccessConfig, +): Promise<{ email: string } | null> { + try { + return await verifyInner(jwt, config); + } catch { + // Fail closed: a malformed segment (bad base64/JSON), a JWKS fetch error, or any + // other unexpected throw must surface as "not authorized" (caller returns 403), + // never as an uncaught 500. The upstream id_token from /callback flows through here. + return null; + } +} + +async function verifyInner( + jwt: string, + config: AccessConfig, +): Promise<{ email: string } | null> { + const parts = jwt.split("."); + if (parts.length !== 3) return null; + + let certs = await getAccessCerts(config.teamDomain); + if (!certs) return null; + + const header = JSON.parse(base64UrlDecode(parts[0])) as { + kid?: string; + alg?: string; + }; + if (!header.kid) return null; + // Only accept RS256 — the algorithm we actually verify below. Rejecting anything + // else closes the classic "alg confusion" downgrade (e.g. `none`/HS256) vector. + if (header.alg !== "RS256") return null; + + let jwk = certs.keys.find((k) => (k as JsonWebKey & { kid?: string }).kid === header.kid); + if (!jwk) { + certs = await getAccessCerts(config.teamDomain, true); + if (!certs) return null; + jwk = certs.keys.find((k) => (k as JsonWebKey & { kid?: string }).kid === header.kid); + if (!jwk) return null; + } + + const key = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + + const signatureBytes = Uint8Array.from( + base64UrlDecode(parts[2]), + (c) => c.charCodeAt(0), + ); + const dataBytes = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); + + const valid = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + key, + signatureBytes, + dataBytes, + ); + if (!valid) return null; + + const payload = JSON.parse(base64UrlDecode(parts[1])) as AccessJwtPayload; + + const now = Math.floor(Date.now() / 1000); + if (payload.exp == null || payload.exp < now) return null; + + const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + if (!aud.includes(config.aud)) return null; + + if (!payload.iss || payload.iss !== config.teamDomain) return null; + + const email = payload.email; + if (!email) return null; + // Case-insensitive: identity providers may return mixed-case local parts. + const normalizedEmail = email.toLowerCase(); + if (!config.allowedEmailDomains.some((d) => normalizedEmail.endsWith(`@${d}`))) { + return null; + } + + return { email }; +} diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..24db85a --- /dev/null +++ b/src/env.ts @@ -0,0 +1,67 @@ +import type { OAuthHelpers } from "@cloudflare/workers-oauth-provider"; + +interface RateLimiter { + limit(options: { key: string }): Promise<{ success: boolean }>; +} + +/** + * Worker environment bindings. + * + * The `ACCESS_*` values come from the Cloudflare Access "SaaS / OIDC" application + * that fronts this MCP server as the upstream identity provider. `CF_ACCESS_TEAM_DOMAIN` + * is reused to verify the Access id_token signature (its `/cdn-cgi/access/certs` JWKS) + * and issuer. See README for the dashboard setup and secret mapping. + */ +export interface Env { + // Plausible + PLAUSIBLE_BASE_URL?: string; + PLAUSIBLE_DEFAULT_SITE_ID?: string; + /** Shared Plausible API key used by the OAuth-protected /mcp endpoint. */ + PLAUSIBLE_API_KEY?: string; + + // Sentry + SENTRY_RELEASE?: string; + + /** + * Optional: the public host this worker is served on (e.g. plausible-mcp.sentry.dev). + * When set, the OAuth authorize/callback endpoints reject requests to any other Host + * as defense in depth (the /callback redirect_uri is derived from request.url). Leave + * unset to disable the check — required for self-hosted deploys on a different host. + */ + SERVICE_HOSTNAME?: string; + + /** + * Optional: comma-separated email domain(s) allowed to sign in to /internal (the "@" + * is optional, e.g. "acme.com" or "acme.com,contractors.acme.com"). Defaults to + * "sentry.io". Self-hosters must set this to their own domain(s) — the upstream Access + * policy alone is not enough, the verified email is also checked here. + */ + ALLOWED_EMAIL_DOMAIN?: string; + + // Cloudflare bindings + RATE_LIMITER?: RateLimiter; + /** KV namespace required by @cloudflare/workers-oauth-provider for tokens/grants/state. */ + OAUTH_KV: KVNamespace; + + // Cloudflare Access (upstream OIDC provider) + /** e.g. https://.cloudflareaccess.com — used for id_token JWKS + issuer check. */ + CF_ACCESS_TEAM_DOMAIN: string; + ACCESS_CLIENT_ID: string; + ACCESS_CLIENT_SECRET: string; + ACCESS_AUTHORIZATION_URL: string; + ACCESS_TOKEN_URL: string; + /** 32-byte hex secret (openssl rand -hex 32) for signing approval/CSRF cookies. */ + COOKIE_ENCRYPTION_KEY: string; + + /** Injected at runtime by OAuthProvider before dispatching to handlers. */ + OAUTH_PROVIDER: OAuthHelpers; +} + +/** + * Authenticated user properties, encrypted into the access token by OAuthProvider + * and surfaced on `ctx.props` inside the protected MCP handler. + */ +export interface Props { + email: string; + [key: string]: unknown; +} diff --git a/src/server.ts b/src/server.ts index bdf7803..cd3a9dc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,6 +10,7 @@ export interface ServerConfig { apiKey: string; baseUrl?: string; defaultSiteId?: string; + recordPii?: boolean; } export function createServer(config: ServerConfig): McpServer { @@ -18,6 +19,10 @@ export function createServer(config: ServerConfig): McpServer { name: "plausible-mcp", version: "0.2.0", }), + { + recordInputs: config.recordPii ?? false, + recordOutputs: config.recordPii ?? false, + }, ); const client = new PlausibleClient({ diff --git a/src/worker.ts b/src/worker.ts index 69d55ea..232f9a6 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,26 +1,30 @@ import * as Sentry from "@sentry/cloudflare"; +import OAuthProvider from "@cloudflare/workers-oauth-provider"; import { createMcpHandler } from "agents/mcp"; import { createServer } from "./server.js"; +import { handleAccessRequest } from "./access-handler.js"; +import type { Env, Props } from "./env.js"; -interface RateLimiter { - limit(options: { key: string }): Promise<{ success: boolean }>; -} +// @sentry/cloudflare doesn't re-export SpanJSON; derive it from the option type. +type SpanJSON = Parameters< + NonNullable +>[0]; -interface Env { - PLAUSIBLE_BASE_URL?: string; - PLAUSIBLE_DEFAULT_SITE_ID?: string; - SENTRY_RELEASE?: string; - RATE_LIMITER?: RateLimiter; -} +const SECURITY_HEADERS: Record = { + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "Cache-Control": "no-store", +}; const CORS_HEADERS: Record = { + ...SECURITY_HEADERS, "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization, Accept, Mcp-Session-Id", "Access-Control-Expose-Headers": "Mcp-Session-Id", - "Cache-Control": "no-store", - "X-Content-Type-Options": "nosniff", }; function corsResponse(response: Response): Response { @@ -28,89 +32,192 @@ function corsResponse(response: Response): Response { for (const [key, value] of Object.entries(CORS_HEADERS)) { patched.headers.set(key, value); } + // Reconstructing a Response can collapse repeated Set-Cookie headers into one on + // some runtimes. Re-apply them individually (read from the original) so the POST + // /authorize redirect keeps BOTH the approved-client and CSRF-clear cookies. + const setCookies = + (response.headers as Headers & { getSetCookie?: () => string[] }) + .getSetCookie?.() ?? []; + if (setCookies.length > 0) { + patched.headers.delete("Set-Cookie"); + for (const cookie of setCookies) patched.headers.append("Set-Cookie", cookie); + } return patched; } -export default Sentry.withSentry( - (env: Env) => ({ +function jsonError(message: string, status: number): Response { + return new Response( + JSON.stringify({ error: message }), + { status, headers: { "Content-Type": "application/json" } }, + ); +} + +function sentryConfig(env: Env) { + return { dsn: "https://de333c4dff86900878d446e663271b2a@o4509446862274560.ingest.us.sentry.io/4511179029020672", release: env.SENTRY_RELEASE, tracesSampleRate: 1.0, - sendDefaultPii: true, - }), - { - async fetch( - request: Request, - env: Env, - ctx: ExecutionContext, - ): Promise { - // Handle CORS preflight - if (request.method === "OPTIONS") { - return new Response(null, { status: 204, headers: CORS_HEADERS }); - } - - // Rate limit by IP - const clientIp = request.headers.get("CF-Connecting-IP") ?? "unknown"; - if (env.RATE_LIMITER) { - const { success } = await env.RATE_LIMITER.limit({ key: clientIp }); - if (!success) { - return corsResponse( - new Response( - JSON.stringify({ error: "Rate limit exceeded. Try again later." }), - { status: 429, headers: { "Content-Type": "application/json", "Retry-After": "60" } }, - ), - ); + sendDefaultPii: false, + beforeSendSpan(span: SpanJSON): SpanJSON { + if (span.data) { + for (const key of Object.keys(span.data)) { + const lower = key.toLowerCase(); + if ( + lower.includes("authorization") || + lower.includes("cookie") || + lower.includes("jwt-assertion") || // Cf-Access-Jwt-Assertion + lower.includes("cf-access") + ) { + span.data[key] = "[Filtered]"; + } } } + return span; + }, + }; +} - // Extract the user's Plausible API key from the Authorization header. - // Each user provides their own key — no shared secret. - const authHeader = request.headers.get("Authorization"); - const apiKey = authHeader?.replace(/^Bearer\s+/i, "").trim(); - - if (!apiKey) { - return corsResponse( - new Response( - JSON.stringify({ - error: - "Missing Plausible API key. Pass it as a Bearer token in the Authorization header.", - }), - { status: 401, headers: { "Content-Type": "application/json" } }, - ), - ); - } +async function rateLimited(request: Request, env: Env): Promise { + if (!env.RATE_LIMITER) return null; + const clientIp = request.headers.get("CF-Connecting-IP") ?? "unknown"; + const { success } = await env.RATE_LIMITER.limit({ key: clientIp }); + if (success) return null; + return new Response( + JSON.stringify({ error: "Rate limit exceeded. Try again later." }), + { status: 429, headers: { "Content-Type": "application/json", "Retry-After": "60" } }, + ); +} - if (apiKey.length < 8) { - return corsResponse( - new Response( - JSON.stringify({ - error: "Invalid API key. Key is too short.", - }), - { status: 401, headers: { "Content-Type": "application/json" } }, - ), - ); - } +/** + * OAuth-protected MCP endpoint (`/internal`) for managed connectors (e.g. Cowork). + * OAuthProvider has already validated the access token; the caller's identity is on + * `ctx.props`. Queries run against the shared server-side Plausible API key. + */ +const internalMcpHandler = { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const props = (ctx as ExecutionContext & { props?: Props }).props; + if (props?.email) { + Sentry.setUser({ email: props.email }); + } - // Create a fresh server per request with the user's own API key - let server; - try { - server = createServer({ - apiKey, - baseUrl: env.PLAUSIBLE_BASE_URL, - defaultSiteId: env.PLAUSIBLE_DEFAULT_SITE_ID, - }); - } catch (error) { - Sentry.captureException(error); - return corsResponse( - new Response( - JSON.stringify({ error: "Server configuration error." }), - { status: 500, headers: { "Content-Type": "application/json" } }, - ), - ); - } + if (!env.PLAUSIBLE_API_KEY) { + return jsonError("Server misconfigured: missing shared Plausible API key.", 500); + } - const response = await createMcpHandler(server)(request, env, ctx); - return corsResponse(response); - }, - } satisfies ExportedHandler, -); + const server = createServer({ + apiKey: env.PLAUSIBLE_API_KEY, + baseUrl: env.PLAUSIBLE_BASE_URL, + defaultSiteId: env.PLAUSIBLE_DEFAULT_SITE_ID, + // Deliberately record tool inputs/outputs into Sentry spans on /internal, attributed + // to the authenticated user via Sentry.setUser({ email }) above. This endpoint is + // SSO-gated to @sentry.io employees and uses a shared server-side key, so per-user + // I/O is what gives us attribution/abuse-tracing on the shared quota. The recorded + // data is analytics query params (site ids, date ranges) and aggregate traffic + // numbers — not personal PII — and Authorization/Cookie/JWT headers are still + // stripped globally by beforeSendSpan. + recordPii: true, + }); + + return createMcpHandler(server, { route: "/internal" })(request, env, ctx); + }, +}; + +/** + * Bring-your-own-key MCP endpoint (`/mcp`) for header-capable clients (Claude Code, + * Cursor, MCP Inspector). Each caller passes their own Plausible API key as a Bearer + * token. Unchanged from the original public contract — not OAuth-protected. + */ +async function handleDirectMcp( + request: Request, + env: Env, + ctx: ExecutionContext, +): Promise { + // Require a well-formed `Bearer ` header — a bare token with no scheme is + // rejected rather than silently accepted, so an accidentally-pasted value fails loudly. + const authHeader = request.headers.get("Authorization") ?? ""; + const match = /^Bearer\s+(.+)$/i.exec(authHeader); + const apiKey = match?.[1]?.trim(); + + if (!apiKey) { + return jsonError( + "Missing or malformed Authorization header. Pass your Plausible API key as `Bearer `.", + 401, + ); + } + + if (apiKey.length < 8) { + return jsonError("Invalid API key. Key is too short.", 401); + } + + let server; + try { + server = createServer({ + apiKey, + baseUrl: env.PLAUSIBLE_BASE_URL, + defaultSiteId: env.PLAUSIBLE_DEFAULT_SITE_ID, + }); + } catch (error) { + Sentry.captureException(error); + return jsonError("Server configuration error.", 500); + } + + return createMcpHandler(server)(request, env, ctx); +} + +/** + * Handles everything OAuthProvider does not route to the protected apiHandler: + * the BYOK `/mcp` endpoint, the OAuth authorize/callback federation, and 404s. + */ +async function handleDefault( + request: Request, + env: Env, + ctx: ExecutionContext, +): Promise { + const { pathname } = new URL(request.url); + + if (pathname === "/mcp" || pathname.startsWith("/mcp/")) { + return handleDirectMcp(request, env, ctx); + } + + // /authorize and /callback (Cloudflare Access federation) + return handleAccessRequest(request, env, ctx); +} + +const oauthProvider = new OAuthProvider({ + apiRoute: "/internal", + apiHandler: internalMcpHandler, + defaultHandler: { fetch: handleDefault } as unknown as ExportedHandler, + authorizeEndpoint: "/authorize", + tokenEndpoint: "/token", + // CIMD (URL-based client_ids) is preferred for managed connectors; DCR via + // /register stays as a fallback so other MCP clients can still self-register. + clientIdMetadataDocumentEnabled: true, + clientRegistrationEndpoint: "/register", + scopesSupported: ["mcp"], + // OAuth 2.1 hardening: S256-only PKCE (no `plain`), and bound token lifetimes so + // an offboarded employee can't reuse tokens indefinitely (default refresh = never + // expires). Access tokens already default to 1h; cap refresh at 24h. + allowPlainPKCE: false, + accessTokenTTL: 60 * 60, // 1 hour + refreshTokenTTL: 24 * 60 * 60, // 24 hours +}); + +const handler = { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext, + ): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + const limited = await rateLimited(request, env); + if (limited) return corsResponse(limited); + + const response = await oauthProvider.fetch(request, env, ctx); + return corsResponse(response); + }, +} satisfies ExportedHandler; + +export default Sentry.withSentry(sentryConfig, handler); diff --git a/src/workers-oauth-utils.ts b/src/workers-oauth-utils.ts new file mode 100644 index 0000000..eda44b2 --- /dev/null +++ b/src/workers-oauth-utils.ts @@ -0,0 +1,672 @@ +// workers-oauth-utils.ts +// OAuth utility functions with CSRF and state validation security fixes. +// Vendored verbatim from Cloudflare's reference MCP-with-Access demo +// (cloudflare/ai/demos/remote-mcp-cf-access). Keep changes minimal so it can +// be diffed against upstream. + +import type { AuthRequest, ClientInfo } from "@cloudflare/workers-oauth-provider"; + +/** + * OAuth 2.1 compliant error class. + * Represents errors that occur during OAuth operations with standardized error codes and descriptions. + */ +export class OAuthError extends Error { + /** + * Creates a new OAuthError + * @param code - The OAuth error code (e.g., "invalid_request", "invalid_grant") + * @param description - Human-readable error description + * @param statusCode - HTTP status code to return (defaults to 400) + */ + constructor( + public code: string, + public description: string, + public statusCode = 400, + ) { + super(description); + this.name = "OAuthError"; + } + + /** + * Converts the error to a standardized OAuth error response + * @returns HTTP Response with JSON error body + */ + toResponse(): Response { + return new Response( + JSON.stringify({ + error: this.code, + error_description: this.description, + }), + { + status: this.statusCode, + headers: { "Content-Type": "application/json" }, + }, + ); + } +} + +/** + * Result from createOAuthState containing the state token and PKCE code challenge + */ +export interface OAuthStateResult { + /** + * The generated state token (signed as {uuid}.{hmac}) to be used in OAuth authorization requests + */ + stateToken: string; + /** + * The PKCE code challenge to include in the upstream authorization request + */ + codeChallenge: string; +} + +/** + * Result from validateOAuthState containing the original OAuth request info and PKCE verifier + */ +export interface ValidateStateResult { + /** + * The original OAuth request information that was stored with the state token + */ + oauthReqInfo: AuthRequest; + + /** + * The PKCE code verifier to include in the upstream token exchange request + */ + codeVerifier: string; +} + +/** + * Result from generateCSRFProtection containing the CSRF token and cookie header + */ +export interface CSRFProtectionResult { + /** + * The generated CSRF token to be embedded in forms + */ + token: string; + + /** + * Set-Cookie header value to send to the client + */ + setCookie: string; +} + +/** + * Result from validateCSRFToken containing the cookie to clear + */ +export interface ValidateCSRFResult { + /** + * Set-Cookie header value to clear the CSRF cookie (one-time use per RFC 9700) + */ + clearCookie: string; +} + +/** + * Sanitizes text content for safe display in HTML by escaping special characters. + * Use this for client names, descriptions, and other text content. + * + * @param text - The unsafe text that might contain HTML special characters + * @returns A safe string with HTML special characters escaped + */ +export function sanitizeText(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Validates a URL for security. Uses whitelist security: only allows https: and http: + * schemes. All other schemes (javascript:, data:, file:, etc.) are rejected. + * + * NOTE: This function only validates the URL structure and scheme. It does NOT + * perform HTML escaping. If you need to use the URL in HTML context (href, src), + * you must also call sanitizeText() on the result. + * + * @param url - The URL to validate + * @returns The validated URL string, or empty string if validation fails + */ +export function sanitizeUrl(url: string): string { + const normalized = url.trim(); + + if (normalized.length === 0) { + return ""; + } + + // RFC 3986: Control characters are not in the allowed character set + // Check C0 (0x00-0x1F) and C1 (0x7F-0x9F) control characters + for (let i = 0; i < normalized.length; i++) { + const code = normalized.charCodeAt(i); + if ((code >= 0x00 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f)) { + return ""; + } + } + + // RFC 3986: Validate URI structure (scheme and path required) + let parsedUrl: URL; + try { + parsedUrl = new URL(normalized); + } catch { + return ""; + } + + // Whitelist only http/https schemes for web resources + const allowedSchemes = ["https", "http"]; + + const scheme = parsedUrl.protocol.slice(0, -1).toLowerCase(); + if (!allowedSchemes.includes(scheme)) { + return ""; + } + + return normalized; +} + +/** + * Generates a new CSRF token and corresponding cookie for form protection + * @returns Object containing the token and Set-Cookie header value + */ +export function generateCSRFProtection(): CSRFProtectionResult { + const csrfCookieName = "__Host-CSRF_TOKEN"; + + const token = crypto.randomUUID(); + const setCookie = `${csrfCookieName}=${token}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=600`; + return { token, setCookie }; +} + +/** + * Validates that the CSRF token from the form matches the token in the cookie. + * Per RFC 9700 Section 2.1, CSRF tokens must be one-time use. + * + * @param formData - The parsed form data containing the CSRF token + * @param request - The HTTP request containing cookies + * @returns Object containing clearCookie header to invalidate the token + * @throws {OAuthError} If CSRF token is missing or mismatched + */ +export function validateCSRFToken(formData: FormData, request: Request): ValidateCSRFResult { + const csrfCookieName = "__Host-CSRF_TOKEN"; + + const tokenFromForm = formData.get("csrf_token"); + + if (!tokenFromForm || typeof tokenFromForm !== "string") { + throw new OAuthError("invalid_request", "Missing CSRF token in form data", 400); + } + + const cookieHeader = request.headers.get("Cookie") || ""; + const cookies = cookieHeader.split(";").map((c) => c.trim()); + const csrfCookie = cookies.find((c) => c.startsWith(`${csrfCookieName}=`)); + const tokenFromCookie = csrfCookie ? csrfCookie.substring(csrfCookieName.length + 1) : null; + + if (!tokenFromCookie) { + throw new OAuthError("invalid_request", "Missing CSRF token cookie", 400); + } + + if (tokenFromForm !== tokenFromCookie) { + throw new OAuthError("invalid_request", "CSRF token mismatch", 400); + } + + // RFC 9700: CSRF tokens must be one-time use. Clear the cookie to prevent reuse. + const clearCookie = `${csrfCookieName}=; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=0`; + + return { clearCookie }; +} + +/** + * Creates and stores OAuth state information, returning a signed state token and PKCE challenge. + * The state token is HMAC-signed to prevent state injection attacks: forged values are rejected + * before any KV operation because the signature check fails first. + */ +export async function createOAuthState( + oauthReqInfo: AuthRequest, + kv: KVNamespace, + secret: string, + stateTTL = 600, +): Promise { + const uuid = crypto.randomUUID(); + const { codeVerifier, codeChallenge } = await generatePKCE(); + + // HMAC-sign the UUID so forged state values are rejected before touching KV + const hmac = await signData(uuid, secret); + const stateToken = `${uuid}.${hmac}`; + + // Store oauthReqInfo and codeVerifier together so they can be retrieved at callback + await kv.put(`oauth:state:${uuid}`, JSON.stringify({ oauthReqInfo, codeVerifier }), { + expirationTtl: stateTTL, + }); + + return { stateToken, codeChallenge }; +} + +/** + * Validates OAuth state from the request, verifying the HMAC signature before any KV lookup, + * and retrieving the stored OAuth request information and PKCE code verifier. + * @throws {OAuthError} If state is missing, has an invalid signature, or is expired/not found in KV + */ +export async function validateOAuthState( + request: Request, + kv: KVNamespace, + secret: string, +): Promise { + const url = new URL(request.url); + const stateFromQuery = url.searchParams.get("state"); + + if (!stateFromQuery) { + throw new OAuthError("invalid_request", "Missing state parameter", 400); + } + + // Verify HMAC signature before touching KV — rejects forged/injected state values immediately + const dotIndex = stateFromQuery.lastIndexOf("."); + if (dotIndex === -1) { + throw new OAuthError("invalid_request", "Invalid state format", 400); + } + const uuid = stateFromQuery.substring(0, dotIndex); + const hmac = stateFromQuery.substring(dotIndex + 1); + + const isValid = await verifySignature(hmac, uuid, secret); + if (!isValid) { + throw new OAuthError("invalid_request", "Invalid state signature", 400); + } + + // Look up by UUID only after signature is verified + const storedDataJson = await kv.get(`oauth:state:${uuid}`); + if (!storedDataJson) { + throw new OAuthError("invalid_request", "Invalid or expired state", 400); + } + + let stored: { oauthReqInfo: AuthRequest; codeVerifier: string }; + try { + stored = JSON.parse(storedDataJson) as { oauthReqInfo: AuthRequest; codeVerifier: string }; + } catch (_e) { + throw new OAuthError("server_error", "Invalid state data", 500); + } + + // Delete state from KV (one-time use) + await kv.delete(`oauth:state:${uuid}`); + + return { oauthReqInfo: stored.oauthReqInfo, codeVerifier: stored.codeVerifier }; +} + +/** + * Checks if a client has been previously approved by the user + */ +export async function isClientApproved( + request: Request, + clientId: string, + cookieSecret: string, +): Promise { + const approvedClients = await getApprovedClientsFromCookie(request, cookieSecret); + return approvedClients?.includes(clientId) ?? false; +} + +/** + * Adds a client to the user's list of approved clients + * @returns Set-Cookie header value with the updated approved clients list + */ +export async function addApprovedClient( + request: Request, + clientId: string, + cookieSecret: string, +): Promise { + const approvedClientsCookieName = "__Host-APPROVED_CLIENTS"; + const THIRTY_DAYS_IN_SECONDS = 2592000; + + const existingApprovedClients = + (await getApprovedClientsFromCookie(request, cookieSecret)) || []; + const updatedApprovedClients = Array.from(new Set([...existingApprovedClients, clientId])); + + const payload = JSON.stringify(updatedApprovedClients); + const signature = await signData(payload, cookieSecret); + const cookieValue = `${signature}.${btoa(payload)}`; + + return `${approvedClientsCookieName}=${cookieValue}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=${THIRTY_DAYS_IN_SECONDS}`; +} + +/** + * Configuration for the approval dialog + */ +export interface ApprovalDialogOptions { + client: ClientInfo | null; + server: { + name: string; + logo?: string; + description?: string; + }; + /** Arbitrary state data passed through the approval flow and returned on completion. */ + state: Record; + csrfToken: string; + setCookie: string; +} + +/** + * Renders an approval dialog for OAuth authorization with CSRF protection. + * The dialog displays information about the client and server and includes a form + * to submit approval with CSRF protection. + */ +export function renderApprovalDialog(request: Request, options: ApprovalDialogOptions): Response { + const { client, server, state, csrfToken, setCookie } = options; + + const encodedState = btoa(JSON.stringify(state)); + + const serverName = sanitizeText(server.name); + const clientName = client?.clientName ? sanitizeText(client.clientName) : "Unknown MCP Client"; + const serverDescription = server.description ? sanitizeText(server.description) : ""; + + // Validate URLs then HTML-escape for safe use in attributes + const logoUrl = server.logo ? sanitizeText(sanitizeUrl(server.logo)) : ""; + const clientUri = client?.clientUri ? sanitizeText(sanitizeUrl(client.clientUri)) : ""; + const policyUri = client?.policyUri ? sanitizeText(sanitizeUrl(client.policyUri)) : ""; + const tosUri = client?.tosUri ? sanitizeText(sanitizeUrl(client.tosUri)) : ""; + + const contacts = + client?.contacts && client.contacts.length > 0 + ? sanitizeText(client.contacts.join(", ")) + : ""; + + const redirectUris = + client?.redirectUris && client.redirectUris.length > 0 + ? client.redirectUris + .map((uri) => { + const validated = sanitizeUrl(uri); + return validated ? sanitizeText(validated) : ""; + }) + .filter((uri) => uri !== "") + : []; + + const htmlContent = ` + + + + + + ${clientName} | Authorization Request + + + +
+
+
+ ${logoUrl ? `` : ""} +

${serverName}

+
+ ${serverDescription ? `

${serverDescription}

` : ""} +
+
+

${clientName || "A new MCP Client"} is requesting access

+
+
+
Name:
+
${clientName}
+
+ ${ + clientUri + ? `
Website:
` + : "" + } + ${ + policyUri + ? `
Privacy Policy:
` + : "" + } + ${ + tosUri + ? `
Terms of Service:
` + : "" + } + ${ + redirectUris.length > 0 + ? `
Redirect URIs:
${redirectUris + .map((uri) => `
${uri}
`) + .join("")}
` + : "" + } + ${ + contacts + ? `
Contact:
${contacts}
` + : "" + } +
+

This MCP Client is requesting to be authorized on ${serverName}. If you approve, you will be redirected to complete authentication.

+
+ + +
+ + +
+
+
+
+ + + `; + + return new Response(htmlContent, { + headers: { + "Content-Security-Policy": "frame-ancestors 'none'", + "Content-Type": "text/html; charset=utf-8", + "Set-Cookie": setCookie, + "X-Frame-Options": "DENY", + }, + }); +} + +// --- Helper Functions --- + +async function generatePKCE(): Promise<{ codeVerifier: string; codeChallenge: string }> { + const verifierBytes = crypto.getRandomValues(new Uint8Array(32)); + const codeVerifier = btoa(String.fromCharCode(...verifierBytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + + const encoder = new TextEncoder(); + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(codeVerifier)); + const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); + + return { codeVerifier, codeChallenge }; +} + +async function getApprovedClientsFromCookie( + request: Request, + cookieSecret: string, +): Promise { + const approvedClientsCookieName = "__Host-APPROVED_CLIENTS"; + + const cookieHeader = request.headers.get("Cookie"); + if (!cookieHeader) return null; + + const cookies = cookieHeader.split(";").map((c) => c.trim()); + const targetCookie = cookies.find((c) => c.startsWith(`${approvedClientsCookieName}=`)); + + if (!targetCookie) return null; + + const cookieValue = targetCookie.substring(approvedClientsCookieName.length + 1); + const parts = cookieValue.split("."); + + if (parts.length !== 2) return null; + + const [signatureHex, base64Payload] = parts; + const payload = atob(base64Payload); + + const isValid = await verifySignature(signatureHex, payload, cookieSecret); + + if (!isValid) return null; + + try { + const approvedClients = JSON.parse(payload); + if ( + !Array.isArray(approvedClients) || + !approvedClients.every((item) => typeof item === "string") + ) { + return null; + } + return approvedClients as string[]; + } catch (_e) { + return null; + } +} + +async function signData(data: string, secret: string): Promise { + const key = await importKey(secret); + const enc = new TextEncoder(); + const signatureBuffer = await crypto.subtle.sign("HMAC", key, enc.encode(data)); + return Array.from(new Uint8Array(signatureBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function verifySignature( + signatureHex: string, + data: string, + secret: string, +): Promise { + if (!signatureHex || !/^[0-9a-f]+$/i.test(signatureHex)) { + return false; + } + const key = await importKey(secret); + const enc = new TextEncoder(); + try { + const signatureBytes = new Uint8Array( + signatureHex.match(/.{1,2}/g)!.map((byte) => Number.parseInt(byte, 16)), + ); + return await crypto.subtle.verify("HMAC", key, signatureBytes.buffer, enc.encode(data)); + } catch (_e) { + return false; + } +} + +async function importKey(secret: string): Promise { + if (!secret) { + throw new Error("cookieSecret is required for signing cookies"); + } + const enc = new TextEncoder(); + return crypto.subtle.importKey( + "raw", + enc.encode(secret), + { hash: "SHA-256", name: "HMAC" }, + false, + ["sign", "verify"], + ); +} + +/** + * Constructs an upstream OAuth authorization URL with query parameters including PKCE + */ +export function getUpstreamAuthorizeUrl(params: { + upstream_url: string; + client_id: string; + redirect_uri: string; + scope: string; + state: string; + code_challenge: string; + code_challenge_method?: string; +}): string { + const url = new URL(params.upstream_url); + url.searchParams.set("client_id", params.client_id); + url.searchParams.set("redirect_uri", params.redirect_uri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", params.scope); + url.searchParams.set("state", params.state); + url.searchParams.set("code_challenge", params.code_challenge); + url.searchParams.set("code_challenge_method", params.code_challenge_method ?? "S256"); + return url.toString(); +} + +/** + * Exchanges an authorization code for tokens from the upstream provider. + * Sends the PKCE code_verifier to bind the exchange to the original authorization request. + * Returns [access_token, id_token, null] on success or [null, null, Response] on error. + */ +export async function fetchUpstreamAuthToken(params: { + upstream_url: string; + client_id: string; + client_secret: string; + code?: string; + redirect_uri: string; + code_verifier: string; +}): Promise<[string, string, null] | [null, null, Response]> { + if (!params.code) { + return [null, null, new Response("Missing authorization code", { status: 400 })]; + } + + const data = new URLSearchParams({ + client_id: params.client_id, + client_secret: params.client_secret, + code: params.code, + grant_type: "authorization_code", + redirect_uri: params.redirect_uri, + code_verifier: params.code_verifier, + }); + + const response = await fetch(params.upstream_url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: data.toString(), + }); + + if (!response.ok) { + const errorText = await response.text(); + return [ + null, + null, + new Response(`Failed to exchange code for token: ${errorText}`, { + status: response.status, + }), + ]; + } + + const body = (await response.json()) as { access_token?: string; id_token?: string }; + + const accessToken = body.access_token; + if (!accessToken) { + return [null, null, new Response("Missing access token", { status: 400 })]; + } + + const idToken = body.id_token; + if (!idToken) { + return [null, null, new Response("Missing id token", { status: 400 })]; + } + return [accessToken, idToken, null]; +} diff --git a/tsconfig.json b/tsconfig.json index 42813d7..0fe4a5b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,14 @@ "forceConsistentCasingInFileNames": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "__tests__", "evals", "src/worker.ts"] + "exclude": [ + "node_modules", + "dist", + "__tests__", + "evals", + "src/worker.ts", + "src/env.ts", + "src/access-handler.ts", + "src/workers-oauth-utils.ts" + ] } diff --git a/tsconfig.worker.json b/tsconfig.worker.json new file mode 100644 index 0000000..4043eb1 --- /dev/null +++ b/tsconfig.worker.json @@ -0,0 +1,14 @@ +{ + // Type-checks the Cloudflare Worker files (worker.ts, env.ts, access-handler.ts, + // workers-oauth-utils.ts) that the main tsconfig.json excludes. They need + // Workers globals instead of Node + DOM, so we swap the lib/types here and only + // type-check (no emit — wrangler/esbuild does the actual bundling). + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "__tests__", "evals", "src/index.ts"] +} diff --git a/wrangler.toml b/wrangler.toml index 3ebc19f..7d37096 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,15 +1,45 @@ name = "plausible-mcp" main = "src/worker.ts" compatibility_date = "2025-01-01" -compatibility_flags = ["nodejs_compat_v2"] - -# Set these via `wrangler secret put PLAUSIBLE_API_KEY` -# or in the Cloudflare dashboard under Settings > Variables +# global_fetch_strictly_public: required for OAuth CIMD (fetching client metadata +# documents) and hardens it — fetch() can only reach public addresses, not internal +# Worker/service bindings, blocking SSRF via a malicious client_id URL. +compatibility_flags = ["nodejs_compat_v2", "global_fetch_strictly_public"] routes = [ { pattern = "plausible-mcp.sentry.dev", custom_domain = true } ] +[vars] +# Public host this worker is served on. Pins the OAuth authorize/callback Host as +# defense in depth. Self-hosting on a different host? Change this to your host (or +# remove it to disable the check). localhost is always allowed for `wrangler dev`. +SERVICE_HOSTNAME = "plausible-mcp.sentry.dev" +# Email domain(s) allowed to sign in to the /internal OAuth endpoint (comma-separated, +# "@" optional). The hosted instance is Sentry-only. Self-hosting? Set this to your own +# domain(s) — the upstream Cloudflare Access policy is not sufficient on its own. +ALLOWED_EMAIL_DOMAIN = "sentry.io" + +[dev] +port = 8787 + +# KV namespace required by @cloudflare/workers-oauth-provider to persist OAuth +# tokens, grants, and authorization state for the /internal (OAuth 2.1) endpoint. +# Create with: npx wrangler kv namespace create OAUTH_KV → paste the id below. +[[kv_namespaces]] +binding = "OAUTH_KV" +id = "" + +# Secrets for the /internal OAuth endpoint (set via `wrangler secret put `). +# Values come from the Cloudflare Access "SaaS / OIDC" app that fronts this server: +# CF_ACCESS_TEAM_DOMAIN https://.cloudflareaccess.com (for id_token JWKS + issuer) +# ACCESS_CLIENT_ID Client ID +# ACCESS_CLIENT_SECRET Client secret +# ACCESS_AUTHORIZATION_URL Authorization endpoint +# ACCESS_TOKEN_URL Token endpoint +# COOKIE_ENCRYPTION_KEY openssl rand -hex 32 +# Plus the shared PLAUSIBLE_API_KEY used by /internal. + [[unsafe.bindings]] name = "RATE_LIMITER" type = "ratelimit"