Skip to content

[server][integrations] OAuth 2.1 for MCP + Cloudflare Worker proxy#238

Open
tswicegood wants to merge 17 commits intoNateBJones-Projects:mainfrom
tswicegood:contrib/tswicegood/oauth-cloudflare-worker
Open

[server][integrations] OAuth 2.1 for MCP + Cloudflare Worker proxy#238
tswicegood wants to merge 17 commits intoNateBJones-Projects:mainfrom
tswicegood:contrib/tswicegood/oauth-cloudflare-worker

Conversation

@tswicegood
Copy link
Copy Markdown

Contribution Type

This PR spans two categories — checking both:

  • Integration (/integrations) — new Cloudflare Worker
  • Repo improvement — adds OAuth 2.1 to the core MCP server (/server)

(Not a recipe, schema, dashboard, or skill.)

What does this do?

Adds OAuth 2.1 (RFC 6749 + PKCE, with RFC 9728 protected-resource metadata) to the core open-brain MCP endpoint, and adds a tiny Cloudflare Worker proxy that gives Supabase-hosted MCP servers a path-less origin so the MCP TypeScript SDK's OAuth discovery resolves correctly. Together, these let Claude Desktop (and other MCP TS SDK clients like mcp-remote) connect via OAuth instead of ?key= URLs — the access key in ?key= ends up in proxy logs, browser history, and Referer headers, so a token-based path is meaningfully safer.

The two pieces are separable in design but need each other in practice:

  • Server-only (OAuth without the Worker) breaks for Claude Desktop because the SDK strips paths from the authorization-server URL when composing OAuth metadata URLs (/.well-known/oauth-authorization-server, /register, /authorize). Supabase Edge Functions are mounted under /functions/v1/<name>/, so those URLs hit the platform gateway and 404. The server work is also blocked by Supabase's platform-level forced Content-Type: text/plain and default-src 'none'; sandbox CSP on every response — fine for JSON, fatal for the HTML login form.
  • Worker-only has nothing to authenticate against — the OAuth state, password verification, and token issuance all live in the Edge Function.

So they're presented as one PR with two parallel tracks merged at the top — [server] commits (OAuth on core MCP) and [integrations] commits (Worker proxy). The merge commits document the split intentionally.

The flow on a working install:

Claude Desktop ──▶ ob-core.<account>.workers.dev/<anything>
                           │
                           ▼ (Worker)
    GET /authorize  ─── render login form HTML ──▶ user's browser
    everything else ──▶ proxy to Supabase
                           │
                           ▼
    https://<ref>.supabase.co/functions/v1/open-brain-mcp/<anything>
                           │
                           ▼
                (Edge Function: OAuth state + password verify + tokens + MCP)

?key= continues to work unchanged — both paths stay supported per client.

Requirements

  • Server side (server/oauth.ts): no new dependencies. New env vars (all optional unless you opt into OAuth):
    • OAUTH_PASSWORD — shared secret users type into the login form
    • OAUTH_JWT_SECRET — HS256 signing key for issued access tokens
    • OAUTH_ISSUER_URL — public URL where the MCP server is reachable (set to the Worker URL)
    • OAUTH_ISSUER_URL_<FUNCTION_NAME> — per-function override for projects running multiple MCPs in one Supabase project
  • Worker side (integrations/cloudflare-oauth-proxy/): a Cloudflare account (free tier works), wrangler CLI, Node.js 20+. No paid services.

Documentation:

  • integrations/cloudflare-oauth-proxy/README.md — Worker setup, including a "Multiple MCP servers" section for projects running additional Edge Functions.
  • docs/01-getting-started.md — added a new optional Step 7b for OAuth setup, plus a tip pointing to the Worker.

Checklist

  • I've read CONTRIBUTING.md
  • My contribution has a README.md with prerequisites, step-by-step instructions, and expected outcome
  • My metadata.json has all required fields (Cloudflare Worker integration)
  • N/A — this contribution doesn't depend on a skill or primitive
  • I tested this on my own Open Brain instance (Claude Desktop OAuth flow end-to-end through the Worker against my deployed core MCP)
  • No credentials, API keys, or secrets are included (the local wrangler.toml is gitignored; only wrangler.toml.example ships)

Test plan

  • Deploy the Worker per integrations/cloudflare-oauth-proxy/README.md Steps 1–2
  • Set OAUTH_PASSWORD, OAUTH_JWT_SECRET, and OAUTH_ISSUER_URL secrets on the Edge Function (Step 3)
  • curl -s "${WORKER_URL}/.well-known/oauth-authorization-server" | jq .issuer — should return the Worker URL, not the Supabase URL
  • curl -sI "${WORKER_URL}/" | grep -i 'www-authenticate' — RFC 9728 header should reference the Worker-scoped resource_metadata URL
  • npx mcp-remote "${WORKER_URL}" --debug — full OAuth flow end-to-end, login form renders as HTML, password submission completes, tools list returns
  • Add the Worker URL as a custom connector in Claude Desktop — tools appear without any ?key= in the URL
  • Sanity check that an existing ?key= URL still works against the Supabase function directly (backwards compat)

🤖 Generated with Claude Code

tswicegood and others added 16 commits April 17, 2026 09:21
Adds OAuth 2.1 with PKCE, dynamic client registration (RFC 7591), and
authorization-server metadata (RFC 8414) as an additive auth path on
the core MCP server. Legacy x-brain-key header and ?key= query param
keep working untouched — users can migrate clients one at a time.

Motivation: the ?key= URL-param form leaks credentials into proxy
logs, browser history, and Referer headers. OAuth replaces it with
short-lived bearer JWTs signed by a server-side secret.

Single-user design: one shared OAUTH_PASSWORD (falls back to
MCP_ACCESS_KEY), JWTs signed with OAUTH_JWT_SECRET, stateless (no DB
tables). Rotating OAUTH_JWT_SECRET invalidates every outstanding token
as a panic button.

Endpoints added:
- GET  /.well-known/oauth-authorization-server
- POST /register        (dynamic client registration)
- GET  /authorize       (password prompt)
- POST /authorize       (verify password, issue code)
- POST /token           (authorization_code + refresh_token grants)

docs/01-getting-started.md: new optional Step 7b with setup, redeploy,
and panic-button rotation instructions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Supabase Edge Functions mount at /functions/v1/<name>/... so Hono
sees that full prefix on every request. The absolute routes
registered in registerOAuthRoutes() (/.well-known/..., /register,
/authorize, /token) never matched, and every OAuth request fell
through to the catch-all auth middleware and got a 401.

Refactor to a single app.use("*") middleware that dispatches by
path suffix and HTTP method. This works for any mount path without
configuration and keeps the module reusable across Edge Functions.

Body-parsing is gated on the path+method match so the MCP transport
still sees an intact request body on pass-through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Supabase Edge Functions see internal URLs (http scheme, no
/functions/v1/ prefix). The discovery doc was advertising those
broken URLs to clients. Rebuild from SUPABASE_URL + function
name (or OAUTH_ISSUER_URL override if set, or X-Forwarded-*
headers as last fallback for self-hosters).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MCP clients (Claude Desktop, etc.) discover the OAuth authorization
server via the WWW-Authenticate challenge header returned on 401
from the MCP endpoint. The header points at a RFC 9728 protected-
resource metadata document, which lists the authorization_servers.

Adds:
  - GET /.well-known/oauth-protected-resource (RFC 9728)
  - WWW-Authenticate: Bearer realm="...", resource_metadata="..."
    header on 401 responses from the catch-all auth middleware
  - Access-Control-Expose-Headers: WWW-Authenticate in CORS so
    browser-based MCP clients (claude.ai) can read it

Also refactors publicRoot() to take the request directly instead
of a path — the function-name segment is always the first path
segment regardless of which endpoint is being handled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MCP TypeScript SDK (Claude Desktop, mcp-remote) strips the URL path
when composing OAuth discovery and endpoint URLs. Since Supabase Edge
Functions mount under /functions/v1/<name>/, the SDK ends up probing
https://<ref>.supabase.co/.well-known/... and /register at the bare
origin, which Supabase's platform gateway 404s. OAuth never completes.

This adds a tiny Cloudflare Worker that gives each MCP server a clean,
path-less workers.dev origin. Every request proxies through to the
Supabase function with /functions/v1/<name>/ prepended; responses pass
through verbatim. The SDK has no path to strip and OAuth resolves.

Structure matches the existing integrations/ pattern (slack-capture,
discord-capture): README.md walkthrough, metadata.json, source in src/,
plus a wrangler.toml.example users copy locally. No real credentials
committed; users substitute their own SUPABASE_REF at setup time.

Also adds a short tip callout at the end of Step 7 in docs/01-getting-
started.md pointing users worried about ?key= URL-leak at this
integration as the token-based alternative.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Claude Desktop and mcp-remote strip the URL path during OAuth discovery
(MCP TypeScript SDK behavior) and can't reach OAuth endpoints hosted
under /functions/v1/<name>/. Point users at the cloudflare-oauth-proxy
integration as the fix, and reclassify the old 7b.4 panic button as 7b.5.

Also adds a warning to 7b.3 that the "without ?key=" flow only works
directly for path-aware clients; Claude Desktop users need the proxy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…roxy URLs

Supabase secrets are project-wide (CLI and Dashboard both), so a single
OAUTH_ISSUER_URL can't distinguish between multiple MCP servers behind
different Cloudflare Worker proxies within the same project.

publicRoot() now checks OAUTH_ISSUER_URL_<FUNCTION_NAME_UPPER> first,
then falls back to the plain OAUTH_ISSUER_URL. The core server uses
the plain name (default) and extensions/recipes set the suffixed
override only when they need a different value — matches the broader
OB1 pattern of "core is default, extensions are the exception."

Function name is derived from the first path segment of the incoming
request, same logic already used for the URL-construction fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…SUER_URL

Supabase secrets are project-wide in both the CLI and Dashboard, not
per-function as the earlier draft assumed. Rewrites Step 3 to match:
the core server uses the plain OAUTH_ISSUER_URL and additional MCP
servers (extensions, recipes) set a suffixed override like
OAUTH_ISSUER_URL_LIFE_CRM_MCP. This follows the broader OB1 pattern
where the core is the default and extensions are the exception.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…orize

Supabase Edge Functions inject these headers by default:
  content-type: text/plain           (overrides Hono's text/html)
  content-security-policy: default-src 'none'; sandbox
  x-content-type-options: nosniff

That causes browsers to display the OAuth login form as raw source text
(plus garbled UTF-8 per Latin-1 default) and blocks form submission
(sandbox without allow-forms). Explicitly set all three on the /authorize
GET and password-retry responses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…assword

Supabase Edge Functions enforce text/plain + sandbox CSP on HTML
responses at the platform level — can't be overridden from inside the
function. Direct /authorize HTML rendering is broken.

Move the login-form rendering to the Cloudflare Worker proxy (see
integrations/cloudflare-oauth-proxy). The Edge Function now:

  - GET /authorize (if hit directly, bypassing the Worker): return 501
    text/plain pointing to the integration README. Claude Desktop's
    flow never hits this since the Worker intercepts GET /authorize.
  - POST /authorize wrong password: 302 back to /authorize with
    ?error=invalid_password and all original params echoed, so the
    Worker re-renders the form with the error. Relative redirect so
    it resolves against the Worker's origin, not Supabase's.

Removes now-unused loginPage + escapeHtml helpers and the HTML-headers
constant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Supabase Edge Functions force text/plain + sandbox CSP on HTML
responses at the platform level; the OAuth login form was unreadable
and unsubmittable through Supabase. Move form rendering to the Worker
itself — it sees the same GET query params (client_id, code_challenge,
etc.) via browser redirect, renders a form POSTing back to /authorize
on the Worker origin, and proxies the POST to Supabase for password
verification.

Param validation (required params, S256 challenge method) moves to the
Worker too, since the Worker decides whether to render or error.

Form error display: if the Edge Function's POST handler sees a wrong
password, it 302s back to GET /authorize?error=invalid_password&...
which the Worker renders with an error message.

~100 lines added. No new dependencies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chrome was blocking the form submit with "Sending form data to ...
violates the following Content Security Policy directive: 'form-action
'self''" — even though the form's action was same-origin. The form-action
directive is strict about redirect chains (applies to every hop after
submission) and our OAuth success flow redirects cross-origin to the MCP
client's callback URL. Scoping form-action tightly enough to permit both
same-origin submit AND arbitrary redirect_uri targets proved fragile
across browsers.

Per CSP Level 3, form-action doesn't fall back to default-src, so omitting
it leaves form submission unrestricted. The password is the real security
control here — CSP was defense-in-depth at best.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two targeted fixes to the auth module:

1. Env vars (OAUTH_PASSWORD, OAUTH_JWT_SECRET) were read at module load
   into const bindings. Supabase Edge Function instances stay warm for
   many requests, so the "rotate OAUTH_JWT_SECRET to invalidate all
   outstanding tokens" panic button didn't take effect until cold start.
   Replace the consts with small getPassword() / getJwtSecret() helpers
   that call Deno.env.get() on every invocation — rotation now lands on
   the next request, regardless of warm-instance lifetime.

2. Wrong-password attempts at POST /authorize had no log trail. Added a
   single console.warn("[oauth] /authorize wrong password", {client_id})
   right before the 302 redirect, so probe attempts show up in Supabase
   Edge Function logs. Password is never logged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ML rationale

Two doc fixes that make this integration drop cleanly into an upstream
repo that doesn't know about the life-crm recipe:

1. README no longer assumes life-crm. Primary setup path is for the
   core Open Brain MCP server only (single Worker, single env var). A
   new "Multiple MCP servers" section explains the override naming
   pattern generically for anyone who runs additional Edge Functions —
   with an abstract "my-extension" example rather than a specific one.

2. README now explains the second reason the Worker exists (beyond
   path-stripping): Supabase Edge Functions force text/plain + sandbox
   CSP on every response at the platform level, which breaks any HTML
   the function tries to return. The Worker serves GET /authorize
   itself so the login form actually renders.

Also:
- wrangler.toml.example keeps only [env.core] as live config, with a
  commented-out block showing how to add a second Worker.
- package.json drops the life-crm-specific scripts; generic deploy/tail
  point at the core env.
- src/index.ts comment uses "open-brain-mcp" as the sole example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds OAuth 2.1 authorization (RFC 6749 + PKCE) to the core open-brain
MCP endpoint, with RFC 9728 protected-resource metadata, per-function
issuer URL overrides, and CSP/content-type handling for the /authorize
form. Designed to work behind the Cloudflare Worker proxy in a separate
branch — together they enable Claude Desktop to connect via OAuth
instead of ?key= URLs.
Adds a tiny Cloudflare Worker that fronts Supabase-hosted MCP servers
on a path-less origin (ob-<name>.<account>.workers.dev) so the MCP
TypeScript SDK's OAuth discovery — which strips URL paths when composing
metadata URLs — can resolve correctly. Worker also serves GET /authorize
HTML directly to bypass Supabase's platform-level Content-Type and CSP
overrides that would otherwise break the login form.

Pairs with the core OAuth 2.1 work in the merged-in oauth-mcp-server
branch; OAUTH_ISSUER_URL secret on the Edge Function points at the
Worker URL.
@github-actions github-actions Bot added documentation Improvements or additions to documentation integration Contribution: MCP extension or capture source labels Apr 25, 2026
@github-actions
Copy link
Copy Markdown

Hey @tswicegood — welcome to Open Brain Source! 👋

Thanks for submitting your first PR. The automated review will run shortly and check things like metadata, folder structure, and README completeness. If anything needs fixing, the review comment will tell you exactly what.

Once the automated checks pass, a human admin will review for quality and clarity. Expect a response within a few days.

If you have questions, check out CONTRIBUTING.md or open an issue.

…uth-cloudflare-worker

# Conflicts:
#	docs/01-getting-started.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation integration Contribution: MCP extension or capture source

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant