diff --git a/.env.selfhost.example b/.env.selfhost.example index 64d4eef19..1a7e27dfd 100644 --- a/.env.selfhost.example +++ b/.env.selfhost.example @@ -30,3 +30,11 @@ ACCESS_ALLOWED_EMAILS= # one (ACCESS_ALLOWED_EMAILS is then ignored) # TEAM_DOMAIN=https://your-team.cloudflareaccess.com # POLICY_AUD=your-access-application-audience-tag + +# Let one Cloudflare Access service token call the MCP inside the shared +# workspace. Client id of the token (the value sent as CF-Access-Client-Id) +# and an email for the service identity. Use a dedicated address such as +# automation@yourdomain.com, not a real user's: user emails are unique, so a +# real address here blocks that person's own login. +# MCP_SERVICE_TOKEN_CLIENT_ID= +# MCP_SERVICE_TOKEN_EMAIL= diff --git a/alchemy.run.ts b/alchemy.run.ts index 9ae6192d4..f2609a2d5 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -270,6 +270,8 @@ const dataEnv = { BETTER_AUTH_SECRET: optionalSecret("BETTER_AUTH_SECRET"), GOOGLE_CLIENT_ID: optionalVar("GOOGLE_CLIENT_ID"), GOOGLE_CLIENT_SECRET: optionalSecret("GOOGLE_CLIENT_SECRET"), + MCP_SERVICE_TOKEN_CLIENT_ID: optionalVar("MCP_SERVICE_TOKEN_CLIENT_ID"), + MCP_SERVICE_TOKEN_EMAIL: optionalVar("MCP_SERVICE_TOKEN_EMAIL"), OPENROUTER_API_KEY: optionalSecret("OPENROUTER_API_KEY"), OPENROUTER_MODEL: optionalVar("OPENROUTER_MODEL"), AUTUMN_SECRET_KEY: optionalSecret("AUTUMN_SECRET_KEY"), diff --git a/connect-claude-mcp.sh b/connect-claude-mcp.sh new file mode 100755 index 000000000..4c0141e87 --- /dev/null +++ b/connect-claude-mcp.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Registers the self-hosted OpenSEO MCP in your own Claude Code (user scope) +# using the seo-automation service token. Prompts for the secret without +# echoing it. The secret is stored only in ~/.claude.json on this Mac. +set -euo pipefail +cd "$(dirname "$0")" +client_id=$(grep '^MCP_SERVICE_TOKEN_CLIENT_ID=' .env.selfhost | cut -d= -f2-) +subdomain=$(grep '^WORKERS_SUBDOMAIN=' .env.selfhost | cut -d= -f2-) +url="https://open-seo-selfhost.${subdomain}/mcp" +secret_file="$HOME/.config/openseo/cf-access-client-secret" +if [ -s "$secret_file" ]; then secret=$(cat "$secret_file"); else read -r -s -p "CF_ACCESS_CLIENT_SECRET: " secret; echo; fi +claude mcp remove --scope user openseo >/dev/null 2>&1 || true +claude mcp add --transport http --scope user openseo "$url" \ + --header "CF-Access-Client-Id: $client_id" \ + --header "CF-Access-Client-Secret: $secret" +unset secret +echo "Registered. Start 'claude' and run /mcp to confirm openseo shows as connected." diff --git a/src/env.d.ts b/src/env.d.ts index db7d478b3..94bd4be11 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -28,6 +28,8 @@ declare namespace Cloudflare { BYPASS_EMAIL_VERIFICATION?: string; TEAM_DOMAIN?: string; POLICY_AUD?: string; + MCP_SERVICE_TOKEN_CLIENT_ID?: string; + MCP_SERVICE_TOKEN_EMAIL?: string; POSTHOG_PUBLIC_KEY?: string; POSTHOG_HOST?: string; BETTER_AUTH_SECRET?: string; diff --git a/src/middleware/ensure-user/cloudflareAccess.test.ts b/src/middleware/ensure-user/cloudflareAccess.test.ts new file mode 100644 index 000000000..83dcb53e3 --- /dev/null +++ b/src/middleware/ensure-user/cloudflareAccess.test.ts @@ -0,0 +1,97 @@ +import type * as Jose from "jose"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockEnv, jwtVerify, resolveSharedWorkspaceContext } = vi.hoisted( + () => ({ + mockEnv: {} as Record, + jwtVerify: vi.fn(), + resolveSharedWorkspaceContext: vi.fn( + async (userId: string, userEmail: string) => ({ + userId, + userEmail, + emailVerified: true, + organizationId: "org_shared", + role: "owner" as const, + }), + ), + }), +); + +vi.mock("cloudflare:workers", () => ({ env: mockEnv })); +vi.mock("jose", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRemoteJWKSet: () => ({}), + jwtVerify: (...args: unknown[]) => jwtVerify(...args), + }; +}); +vi.mock("./delegated", () => ({ resolveSharedWorkspaceContext })); + +import { resolveCloudflareAccessContext } from "./cloudflareAccess"; + +function headersWith(token: string) { + return new Headers({ "cf-access-jwt-assertion": token }); +} + +describe("resolveCloudflareAccessContext", () => { + beforeEach(() => { + for (const key of Object.keys(mockEnv)) delete mockEnv[key]; + mockEnv.TEAM_DOMAIN = "https://martechs.cloudflareaccess.com"; + mockEnv.POLICY_AUD = "aud-123"; + jwtVerify.mockReset(); + resolveSharedWorkspaceContext.mockClear(); + }); + + it("resolves a user identity token by sub and email", async () => { + jwtVerify.mockResolvedValue({ + payload: { sub: "user-1", email: "uday@martechs.io" }, + }); + + const context = await resolveCloudflareAccessContext(headersWith("jwt")); + + expect(context.userId).toBe("user-1"); + expect(resolveSharedWorkspaceContext).toHaveBeenCalledWith( + "user-1", + "uday@martechs.io", + ); + }); + + it("maps the configured service token to the shared workspace", async () => { + mockEnv.MCP_SERVICE_TOKEN_CLIENT_ID = "abc123.access"; + mockEnv.MCP_SERVICE_TOKEN_EMAIL = "uday@martechs.io"; + jwtVerify.mockResolvedValue({ + payload: { sub: "", common_name: "abc123.access" }, + }); + + const context = await resolveCloudflareAccessContext(headersWith("jwt")); + + expect(context.userId).toBe("service:abc123.access"); + expect(resolveSharedWorkspaceContext).toHaveBeenCalledWith( + "service:abc123.access", + "uday@martechs.io", + ); + }); + + it("rejects a service token that is not the configured one", async () => { + mockEnv.MCP_SERVICE_TOKEN_CLIENT_ID = "abc123.access"; + mockEnv.MCP_SERVICE_TOKEN_EMAIL = "uday@martechs.io"; + jwtVerify.mockResolvedValue({ + payload: { sub: "", common_name: "other.access" }, + }); + + await expect( + resolveCloudflareAccessContext(headersWith("jwt")), + ).rejects.toMatchObject({ code: "UNAUTHENTICATED" }); + }); + + it("rejects any service token when none is configured", async () => { + jwtVerify.mockResolvedValue({ + payload: { sub: "", common_name: "abc123.access" }, + }); + + await expect( + resolveCloudflareAccessContext(headersWith("jwt")), + ).rejects.toMatchObject({ code: "UNAUTHENTICATED" }); + }); +}); diff --git a/src/middleware/ensure-user/cloudflareAccess.ts b/src/middleware/ensure-user/cloudflareAccess.ts index e634b27e4..e1937d73f 100644 --- a/src/middleware/ensure-user/cloudflareAccess.ts +++ b/src/middleware/ensure-user/cloudflareAccess.ts @@ -90,9 +90,23 @@ export async function resolveCloudflareAccessContext( const userId = typeof payload.sub === "string" ? payload.sub : null; const userEmail = typeof payload.email === "string" ? payload.email : null; - if (!userId || !userEmail) { - throw new AppError("UNAUTHENTICATED"); + if (userId && userEmail) { + return resolveSharedWorkspaceContext(userId, userEmail); } - return resolveSharedWorkspaceContext(userId, userEmail); + // Service tokens (Access "Service Auth" policies) carry no email: only a + // `common_name` equal to the token's client id. Exactly one configured token + // is allowed in, as its own user inside the shared workspace. The email must + // be dedicated to the token: user emails are unique, so reusing a person's + // address would block that person's own login. + const serviceClientId = env.MCP_SERVICE_TOKEN_CLIENT_ID?.trim(); + const serviceEmail = env.MCP_SERVICE_TOKEN_EMAIL?.trim(); + const commonName = + typeof payload.common_name === "string" ? payload.common_name : null; + + if (serviceClientId && serviceEmail && commonName === serviceClientId) { + return resolveSharedWorkspaceContext(`service:${commonName}`, serviceEmail); + } + + throw new AppError("UNAUTHENTICATED"); } diff --git a/store-api-token.sh b/store-api-token.sh new file mode 100755 index 000000000..571d91431 --- /dev/null +++ b/store-api-token.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# One-time: stores a read-only Cloudflare API token so Claude can list the +# Access service tokens and application policies to debug the setup. +# Saved to ~/.config/openseo/cf-api-token, readable only by your user. +set -euo pipefail +dir="$HOME/.config/openseo" +mkdir -p "$dir" && chmod 700 "$dir" +read -r -s -p "Cloudflare API token (paste, then Enter): " tok; echo +tok="${tok//[$'\r\n\t ']/}" +if [ ${#tok} -lt 20 ]; then echo "Too short (${#tok}). Nothing saved."; exit 1; fi +printf '%s' "$tok" > "$dir/cf-api-token" +chmod 600 "$dir/cf-api-token" +status=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $tok" https://api.cloudflare.com/client/v4/user/tokens/verify) +unset tok +echo "Saved. Token verify HTTP $status (200 means it works)." diff --git a/store-secret.sh b/store-secret.sh new file mode 100755 index 000000000..439044ed2 --- /dev/null +++ b/store-secret.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# One-time: stores the seo-automation Access client secret in a file only your +# user can read (~/.config/openseo/cf-access-client-secret). The verify and +# connect scripts read it from there so nobody has to retype it. +set -euo pipefail +dir="$HOME/.config/openseo" +mkdir -p "$dir" && chmod 700 "$dir" +read -r -s -p "CF_ACCESS_CLIENT_SECRET (paste, then Enter): " secret; echo +secret="${secret//[$'\r\n\t ']/}" +if [ ${#secret} -lt 20 ]; then echo "That looks too short (${#secret} characters). Nothing saved."; exit 1; fi +echo "Received ${#secret} characters." +printf '%s' "$secret" > "$dir/cf-access-client-secret" +chmod 600 "$dir/cf-access-client-secret" +unset secret +echo "Saved ${#secret:-0} characters to $dir/cf-access-client-secret" diff --git a/verify-mcp.sh b/verify-mcp.sh new file mode 100755 index 000000000..aed92a713 --- /dev/null +++ b/verify-mcp.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Verifies that the seo-automation service token can reach the OpenSEO MCP. +# Prompts for the Access client secret without echoing it and never stores it. +set -euo pipefail +cd "$(dirname "$0")" +client_id=$(grep '^MCP_SERVICE_TOKEN_CLIENT_ID=' .env.selfhost | cut -d= -f2-) +subdomain=$(grep '^WORKERS_SUBDOMAIN=' .env.selfhost | cut -d= -f2-) +url="https://open-seo-selfhost.${subdomain}/mcp" +secret_file="$HOME/.config/openseo/cf-access-client-secret" +if [ -s "$secret_file" ]; then secret=$(cat "$secret_file"); else read -r -s -p "CF_ACCESS_CLIENT_SECRET: " secret; echo; fi +body='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"verify","version":"0"}}}' +health=$(curl -s -o /dev/null -w '%{http_code}' "https://open-seo-selfhost.${subdomain}/api/health" \ + -H "CF-Access-Client-Id: $client_id" -H "CF-Access-Client-Secret: $secret") +echo "/api/health with service token -> HTTP $health (200 means the token and policy are right)" +mcp_get=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$url" \ + -H "CF-Access-Client-Id: $client_id" -H "CF-Access-Client-Secret: $secret") +echo "GET /mcp with service token -> ${mcp_get:0:110}" +health_post=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -X POST "https://open-seo-selfhost.${subdomain}/api/health" \ + -H "CF-Access-Client-Id: $client_id" -H "CF-Access-Client-Secret: $secret") +echo "POST /api/health with service token -> ${health_post:0:110}" +response=$(curl -s -w '\nHTTP %{http_code} %{redirect_url}' -X POST "$url" \ + -H "CF-Access-Client-Id: $client_id" \ + -H "CF-Access-Client-Secret: $secret" \ + -H "content-type: application/json" \ + -H "accept: application/json, text/event-stream" \ + -d "$body") +unset secret +echo "$response" | tail -1 | cut -c1-120 +if echo "$response" | grep -q '"serverInfo"'; then + echo "OK: the service token reaches the MCP and the server answered." +else + echo "FAILED. Response (first 300 chars):" + echo "$response" | head -c 300; echo +fi