diff --git a/.gitignore b/.gitignore index 1aa4e69..94b9c0c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,10 @@ node_modules/ dist/ .env .env.local +.dev.vars *.tsbuildinfo .sentry-build/ # Sentry Config File .sentryclirc +.wrangler/ diff --git a/bun.lock b/bun.lock index 60950a5..93bf97f 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "name": "plausible-mcp", "dependencies": { + "@cloudflare/workers-oauth-provider": "^0.4.0", "@cloudflare/workers-types": "^4.20260317.1", "@modelcontextprotocol/sdk": "^1.7.0", "@sentry/cloudflare": "^10.45.0", @@ -36,6 +37,8 @@ "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], + "@cloudflare/workers-oauth-provider": ["@cloudflare/workers-oauth-provider@0.4.0", "", {}, "sha512-UtbV8hjC2NloB+Ds6J6v/9HiG8rx8MbdeYGCyFwOACT5vANWzDL6SKo3W5UZymsXiameAgC7jAmtUx4cc+Qpaw=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260317.1", "", {}, "sha512-+G4eVwyCpm8Au1ex8vQBCuA9wnwqetz4tPNRoB/53qvktERWBRMQnrtvC1k584yRE3emMThtuY0gWshvSJ++PQ=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], diff --git a/package.json b/package.json index 142fd26..caa9aa6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ ], "license": "MIT", "dependencies": { + "@cloudflare/workers-oauth-provider": "^0.4.0", "@cloudflare/workers-types": "^4.20260317.1", "@modelcontextprotocol/sdk": "^1.7.0", "@sentry/cloudflare": "^10.45.0", diff --git a/src/auth-handler.ts b/src/auth-handler.ts new file mode 100644 index 0000000..1ffc8ba --- /dev/null +++ b/src/auth-handler.ts @@ -0,0 +1,125 @@ +import type { OAuthHelpers, AuthRequest } from "@cloudflare/workers-oauth-provider"; + +export interface AuthEnv { + OAUTH_PROVIDER: OAuthHelpers; + OAUTH_KV: KVNamespace; + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; +} + +/** + * Handles the OAuth authorization flow using Google SSO. + * Only @sentry.io emails are allowed. + */ +export const authHandler: ExportedHandler = { + async fetch(request: Request, env: AuthEnv): Promise { + const url = new URL(request.url); + + if (url.pathname === "/authorize") { + return handleAuthorize(request, env); + } + if (url.pathname === "/callback") { + return handleCallback(request, env); + } + + return new Response("Not Found", { status: 404 }); + }, +}; + +async function handleAuthorize(request: Request, env: AuthEnv): Promise { + // Parse the OAuth authorization request from the MCP client + const oauthReq = await env.OAUTH_PROVIDER.parseAuthRequest(request); + if (!oauthReq.clientId) { + return new Response("Invalid OAuth request", { status: 400 }); + } + + // Store the OAuth request in KV so we can retrieve it after Google callback + const stateKey = crypto.randomUUID(); + await env.OAUTH_KV.put(`auth:${stateKey}`, JSON.stringify(oauthReq), { + expirationTtl: 600, // 10 minutes + }); + + // Redirect to Google OAuth + const googleAuthUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth"); + googleAuthUrl.searchParams.set("client_id", env.GOOGLE_CLIENT_ID); + googleAuthUrl.searchParams.set("redirect_uri", `${new URL(request.url).origin}/callback`); + googleAuthUrl.searchParams.set("response_type", "code"); + googleAuthUrl.searchParams.set("scope", "openid email profile"); + googleAuthUrl.searchParams.set("state", stateKey); + googleAuthUrl.searchParams.set("hd", "sentry.io"); // Restrict to Sentry domain + + return Response.redirect(googleAuthUrl.toString(), 302); +} + +async function handleCallback(request: Request, env: AuthEnv): Promise { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const stateKey = url.searchParams.get("state"); + + if (!code || !stateKey) { + return new Response("Missing code or state", { status: 400 }); + } + + // Retrieve the original OAuth request + const stored = await env.OAUTH_KV.get(`auth:${stateKey}`); + if (!stored) { + return new Response("Authorization request expired", { status: 400 }); + } + await env.OAUTH_KV.delete(`auth:${stateKey}`); + const oauthReq: AuthRequest = JSON.parse(stored); + + // Exchange Google auth code for tokens + const tokenRes = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + redirect_uri: `${url.origin}/callback`, + grant_type: "authorization_code", + }), + }); + + if (!tokenRes.ok) { + return new Response("Failed to exchange Google auth code", { status: 502 }); + } + + const tokens = (await tokenRes.json()) as { access_token: string }; + + // Get user info from Google + const userRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + + if (!userRes.ok) { + return new Response("Failed to get user info", { status: 502 }); + } + + const user = (await userRes.json()) as { + email: string; + name: string; + hd?: string; + }; + + // Verify @sentry.io email + if (user.hd !== "sentry.io" || !user.email.endsWith("@sentry.io")) { + return new Response("Access restricted to @sentry.io accounts", { + status: 403, + }); + } + + // Complete the OAuth authorization — issue our own token + const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({ + request: oauthReq, + userId: user.email, + metadata: { label: `${user.name} (${user.email})` }, + scope: oauthReq.scope, + props: { + email: user.email, + name: user.name, + }, + }); + + return Response.redirect(redirectTo, 302); +} diff --git a/src/worker.ts b/src/worker.ts index 69d55ea..66f414b 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,6 +1,9 @@ import * as Sentry from "@sentry/cloudflare"; +import OAuthProvider from "@cloudflare/workers-oauth-provider"; import { createMcpHandler } from "agents/mcp"; import { createServer } from "./server.js"; +import { authHandler } from "./auth-handler.js"; +import type { OAuthHelpers } from "@cloudflare/workers-oauth-provider"; interface RateLimiter { limit(options: { key: string }): Promise<{ success: boolean }>; @@ -9,8 +12,13 @@ interface RateLimiter { interface Env { PLAUSIBLE_BASE_URL?: string; PLAUSIBLE_DEFAULT_SITE_ID?: string; + PLAUSIBLE_API_KEY?: string; SENTRY_RELEASE?: string; RATE_LIMITER?: RateLimiter; + OAUTH_KV: KVNamespace; + OAUTH_PROVIDER: OAuthHelpers; + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; } const CORS_HEADERS: Record = { @@ -31,86 +39,157 @@ function corsResponse(response: Response): Response { return patched; } -export default Sentry.withSentry( - (env: Env) => ({ +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" } }, - ), - ); - } - } - - // 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" } }, - ), - ); - } - - 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" } }, - ), - ); - } - - // 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" } }, - ), - ); - } - - const response = await createMcpHandler(server)(request, env, ctx); - return corsResponse(response); - }, - } satisfies ExportedHandler, -); + }; +} + +/** + * API handler for /internal — OAuth-protected MCP endpoint. + * Uses the shared team Plausible API key. + */ +const internalApiHandler = Sentry.withSentry(sentryConfig, { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext, + ): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + if (!env.PLAUSIBLE_API_KEY) { + return corsResponse( + new Response( + JSON.stringify({ error: "Server misconfigured: missing shared Plausible API key." }), + { status: 500, headers: { "Content-Type": "application/json" } }, + ), + ); + } + + const server = createServer({ + apiKey: env.PLAUSIBLE_API_KEY, + baseUrl: env.PLAUSIBLE_BASE_URL, + defaultSiteId: env.PLAUSIBLE_DEFAULT_SITE_ID, + }); + + const response = await createMcpHandler(server)(request, env, ctx); + return corsResponse(response); + }, +} satisfies ExportedHandler); + +/** + * Default handler — routes /mcp (direct Bearer), /authorize, /callback, etc. + * Everything not matched by apiRoute ("/internal") lands here. + */ +const defaultHandler = Sentry.withSentry(sentryConfig, { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext, + ): Promise { + const url = new URL(request.url); + + // CORS preflight for any route + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + // Google OAuth flow — /authorize and /callback + if (url.pathname === "/authorize" || url.pathname === "/callback") { + return authHandler.fetch!(request, env, ctx); + } + + // Direct Bearer token MCP endpoint — existing flow, unchanged + if (url.pathname === "/mcp" || url.pathname.startsWith("/mcp/")) { + return handleDirectMcp(request, env, ctx); + } + + return new Response("Not Found", { status: 404 }); + }, +} satisfies ExportedHandler); + +/** + * Existing direct Bearer token MCP flow for CLI users. + */ +async function handleDirectMcp( + request: Request, + env: Env, + ctx: ExecutionContext, +): Promise { + // 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" } }, + ), + ); + } + } + + // Extract the user's Plausible API key from the Authorization header. + 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" } }, + ), + ); + } + + 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" } }, + ), + ); + } + + 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" } }, + ), + ); + } + + const response = await createMcpHandler(server)(request, env, ctx); + return corsResponse(response); +} + +export default new OAuthProvider({ + apiRoute: "/internal", + apiHandler: internalApiHandler, + defaultHandler, + authorizeEndpoint: "/authorize", + tokenEndpoint: "/token", + clientRegistrationEndpoint: "/register", + scopesSupported: ["mcp:tools"], + accessTokenTTL: 86400, // 24 hours +}); diff --git a/tsconfig.json b/tsconfig.json index 42813d7..5b89a93 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,5 @@ "forceConsistentCasingInFileNames": true }, "include": ["src"], - "exclude": ["node_modules", "dist", "__tests__", "evals", "src/worker.ts"] + "exclude": ["node_modules", "dist", "__tests__", "evals", "src/worker.ts", "src/auth-handler.ts"] } diff --git a/wrangler.toml b/wrangler.toml index 3ebc19f..c003bb5 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -3,13 +3,20 @@ 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 - routes = [ { pattern = "plausible-mcp.sentry.dev", custom_domain = true } ] +[dev] +port = 8787 + +# OAuth KV store — stores client registrations, grants, and tokens +# Production: wrangler kv namespace create plausible-mcp-oauth +# Local dev uses --local which simulates KV automatically +[[kv_namespaces]] +binding = "OAUTH_KV" +id = "PLACEHOLDER_REPLACE_BEFORE_DEPLOY" + [[unsafe.bindings]] name = "RATE_LIMITER" type = "ratelimit"