From aad07a141b199c8f02e25649b74539a420dc1e29 Mon Sep 17 00:00:00 2001 From: 404sand808s Date: Fri, 10 Apr 2026 12:52:19 -0400 Subject: [PATCH] Add Cloudflare OAuth proxy integration for self-hosted Hindsight (#922) Adds an OAuth 2.1 proxy Worker that connects cloud MCP clients (claude.ai, Claude Code, Codex) to a self-hosted Hindsight instance via Cloudflare Workers and Tunnel. Co-authored-by: Claude Opus 4.6 (1M context) --- .../cloudflare-oauth-proxy/.gitignore | 3 + .../cloudflare-oauth-proxy/README.md | 94 +++++++ .../cloudflare-oauth-proxy/package.json | 17 ++ .../cloudflare-oauth-proxy/src/index.ts | 247 ++++++++++++++++++ .../cloudflare-oauth-proxy/wrangler.toml | 21 ++ 5 files changed, 382 insertions(+) create mode 100644 hindsight-integrations/cloudflare-oauth-proxy/.gitignore create mode 100644 hindsight-integrations/cloudflare-oauth-proxy/README.md create mode 100644 hindsight-integrations/cloudflare-oauth-proxy/package.json create mode 100644 hindsight-integrations/cloudflare-oauth-proxy/src/index.ts create mode 100644 hindsight-integrations/cloudflare-oauth-proxy/wrangler.toml diff --git a/hindsight-integrations/cloudflare-oauth-proxy/.gitignore b/hindsight-integrations/cloudflare-oauth-proxy/.gitignore new file mode 100644 index 00000000..d391312c --- /dev/null +++ b/hindsight-integrations/cloudflare-oauth-proxy/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.wrangler/ diff --git a/hindsight-integrations/cloudflare-oauth-proxy/README.md b/hindsight-integrations/cloudflare-oauth-proxy/README.md new file mode 100644 index 00000000..906061e2 --- /dev/null +++ b/hindsight-integrations/cloudflare-oauth-proxy/README.md @@ -0,0 +1,94 @@ +# Cloudflare OAuth Proxy for Self-Hosted Hindsight + +An [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12) proxy that connects cloud-based MCP clients (such as [claude.ai](https://claude.ai), [Claude Code](../claude-code/), and [Codex](../codex/)) to a self-hosted [Hindsight](https://vectorize.io/hindsight) instance. Built on [Cloudflare Workers](https://developers.cloudflare.com/workers/) using the [`@cloudflare/workers-oauth-provider`](https://github.com/cloudflare/workers-oauth-provider) library. + +## Why + +Cloud-based MCP clients require an OAuth 2.1 flow to connect to remote MCP servers. Self-hosted Hindsight instances typically sit behind a private network or Cloudflare Tunnel, and don't natively expose an OAuth endpoint. This Worker bridges that gap: it handles the OAuth dance on a public domain, authenticates the user with a simple password gate, and proxies authenticated MCP traffic to your Hindsight origin through a Cloudflare Tunnel. + +## Architecture + +``` +Cloud MCP Client (claude.ai, Claude Code, Codex) + | + | HTTPS + OAuth 2.1 + v +Cloudflare Worker (this proxy) + - OAuth 2.1 authorization server + - Dynamic client registration (RFC 7591) + - PKCE (S256 only) + - CORS restricted to allowlisted origins + | + | HTTPS + Cloudflare Tunnel + v +Self-hosted Hindsight (Docker) +``` + +## Prerequisites + +- A [Cloudflare](https://www.cloudflare.com/) account with a domain +- A running self-hosted Hindsight instance (see [self-hosting quickstart](https://vectorize.io/hindsight/quickstart/self-hosting)) +- A [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) exposing your Hindsight instance +- [Node.js](https://nodejs.org/) (v18+) + +## Setup + +### 1. Install dependencies + +```bash +cd hindsight-integrations/cloudflare-oauth-proxy +npm install +``` + +### 2. Configure `wrangler.toml` + +Copy the included `wrangler.toml` and update the placeholder values: + +- `HINDSIGHT_ORIGIN`: Your Cloudflare Tunnel origin URL (e.g., `https://hindsight-origin.yourdomain.com`) +- `kv_namespaces.id`: Create a KV namespace via `npx wrangler kv namespace create OAUTH_KV` and paste the returned ID +- `routes.pattern` / `routes.zone_name`: Your public-facing domain for the proxy + +### 3. Set secrets + +```bash +npx wrangler secret put SESSION_SECRET # Password for the login page +npx wrangler secret put PROXY_SECRET # X-Proxy-Secret header value (must match your origin WAF rule) +npx wrangler secret put HINDSIGHT_API_TOKEN # Bearer token for the Hindsight API +npx wrangler secret put ALLOWED_EMAIL # Your email (used as the OAuth user identity) +``` + +### 4. Deploy + +```bash +npm run deploy +``` + +### 5. Secure your origin + +Add a WAF rule on your Cloudflare Tunnel origin hostname to block requests that don't carry the correct `X-Proxy-Secret` header. This ensures only the Worker can reach your Hindsight instance. + +## Connecting clients + +Once deployed, add the Worker URL as a remote MCP server in your client: + +- **claude.ai**: Settings > MCP Servers > Add > enter `https://hindsight.yourdomain.com/mcp` +- **Claude Code**: `claude mcp add hindsight-remote https://hindsight.yourdomain.com/mcp --transport http` +- **Codex**: Configure via the MCP settings with the same URL + +On first connection, you'll be redirected to a login page. Enter the `SESSION_SECRET` password to authorize. This only needs to happen once per OAuth session. + +## Secrets reference + +| Secret | Purpose | +|--------|---------| +| `SESSION_SECRET` | Password shown on the login page to authorize a session | +| `PROXY_SECRET` | Value sent as `X-Proxy-Secret` header to the origin (for WAF validation) | +| `HINDSIGHT_API_TOKEN` | Bearer token for authenticating with the Hindsight API | +| `ALLOWED_EMAIL` | Your email address, used as the OAuth user identity | + +## Security notes + +- CORS is restricted to `claude.ai` origins by default. To support additional clients (e.g., ChatGPT), add their origins to the `ALLOWED_ORIGINS` set in `src/index.ts`. +- PKCE is enforced with S256 only (plain PKCE is not advertised). +- OAuth state is stored in Cloudflare KV with a 5-minute TTL. +- The proxy strips the client's `Authorization` header and replaces it with the configured `HINDSIGHT_API_TOKEN` before forwarding to the origin. diff --git a/hindsight-integrations/cloudflare-oauth-proxy/package.json b/hindsight-integrations/cloudflare-oauth-proxy/package.json new file mode 100644 index 00000000..cb788eb2 --- /dev/null +++ b/hindsight-integrations/cloudflare-oauth-proxy/package.json @@ -0,0 +1,17 @@ +{ + "name": "hindsight-cloudflare-oauth-proxy", + "version": "1.0.0", + "private": true, + "description": "OAuth 2.1 proxy for connecting cloud MCP clients to self-hosted Hindsight via Cloudflare Workers", + "scripts": { + "dev": "npx wrangler dev", + "deploy": "npx wrangler deploy" + }, + "dependencies": { + "@cloudflare/workers-oauth-provider": "^0.0.4" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250327.0", + "wrangler": "^4.0.0" + } +} diff --git a/hindsight-integrations/cloudflare-oauth-proxy/src/index.ts b/hindsight-integrations/cloudflare-oauth-proxy/src/index.ts new file mode 100644 index 00000000..4d6bf15b --- /dev/null +++ b/hindsight-integrations/cloudflare-oauth-proxy/src/index.ts @@ -0,0 +1,247 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; +import OAuthProvider from "@cloudflare/workers-oauth-provider"; + +interface Env { + OAUTH_KV: KVNamespace; + OAUTH_PROVIDER: any; + HINDSIGHT_ORIGIN: string; + ALLOWED_EMAIL: string; + SESSION_SECRET: string; + PROXY_SECRET: string; // add as Wrangler secret + HINDSIGHT_API_TOKEN: string; // add as Wrangler secret +} + +// --- HTML escaping --- +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} + +// --- CORS Allowlist --- +const ALLOWED_ORIGINS = new Set([ + "https://claude.ai", + "https://www.claude.ai", +]); + +function corsHeaders(origin: string): Record { + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "*", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Max-Age": "86400", + }; +} + +function stripCorsHeaders(response: Response): Response { + const cleaned = new Response(response.body, response); + cleaned.headers.delete("Access-Control-Allow-Origin"); + cleaned.headers.delete("Access-Control-Allow-Methods"); + cleaned.headers.delete("Access-Control-Allow-Headers"); + cleaned.headers.delete("Access-Control-Max-Age"); + return cleaned; +} + +function applyCors(response: Response, origin: string | null): Response { + if (!origin || !ALLOWED_ORIGINS.has(origin)) { + // Strip any CORS headers the library may have added + if (response.headers.has("Access-Control-Allow-Origin")) { + return stripCorsHeaders(response); + } + return response; + } + const patched = stripCorsHeaders(response); + for (const [k, v] of Object.entries(corsHeaders(origin))) { + patched.headers.set(k, v); + } + return patched; +} + +// --- MCP Proxy Handler --- +export class HindsightProxy extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const props = (this.ctx as any).props || {}; + console.log(`MCP request from ${props.email}: ${request.method} ${new URL(request.url).pathname}`); + + const url = new URL(request.url); + const originUrl = new URL(this.env.HINDSIGHT_ORIGIN); + url.hostname = originUrl.hostname; + url.port = originUrl.port; + url.protocol = originUrl.protocol; + + const headers = new Headers(request.headers); + headers.delete("Authorization"); + headers.set("X-Proxy-Secret", this.env.PROXY_SECRET); + headers.set("Authorization", `Bearer ${this.env.HINDSIGHT_API_TOKEN}`); + + try { + const response = await fetch(url.toString(), { + method: request.method, + headers, + body: request.body ?? null, + }); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } catch (err) { + console.error("Failed to proxy to Hindsight:", err); + return new Response(JSON.stringify({ error: "Backend unavailable" }), { + status: 502, + headers: { "Content-Type": "application/json" }, + }); + } + } +} + +function loginPage(stateKey: string, error?: string): string { + return '' + + 'Hindsight MCP' + + '' + + '
' + + '

Hindsight MCP

' + + '

Authorize Claude to access your memory.

' + + (error ? '

' + escapeHtml(error) + '

' : '') + + '
' + + '' + + '' + + '' + + '
' + + '

You only need to do this once per session.

' + + '
'; +} + +const defaultHandler = { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(request.url); + + if (url.pathname === "/health") { + return new Response(JSON.stringify({ status: "ok" }), { + headers: { "Content-Type": "application/json" }, + }); + } + + if (url.pathname === "/authorize" && request.method === "GET") { + const oauthReqInfo = await env.OAUTH_PROVIDER.parseAuthRequest(request); + console.log("GET /authorize - parsed oauthReqInfo, clientId:", oauthReqInfo.clientId); + + const stateKey = crypto.randomUUID(); + await env.OAUTH_KV.put("auth_state:" + stateKey, JSON.stringify(oauthReqInfo), { expirationTtl: 300 }); + + return new Response(loginPage(stateKey), { + headers: { "Content-Type": "text/html" }, + }); + } + + if (url.pathname === "/authorize" && request.method === "POST") { + const formData = await request.formData(); + const password = formData.get("password") as string; + const stateKey = formData.get("stateKey") as string; + + if (!password || password !== env.SESSION_SECRET) { + return new Response(loginPage(stateKey || "", "Incorrect password."), { + status: 401, + headers: { "Content-Type": "text/html" }, + }); + } + + if (!stateKey) { + return new Response("Missing state. Please try connecting again from Claude.", { status: 400 }); + } + + const stored = await env.OAUTH_KV.get("auth_state:" + stateKey); + await env.OAUTH_KV.delete("auth_state:" + stateKey); + + if (!stored) { + return new Response("Authorization expired. Please try connecting again from Claude.", { status: 400 }); + } + + const oauthReqInfo = JSON.parse(stored); + console.log("POST /authorize - completing authorization for client:", oauthReqInfo.clientId); + + try { + const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({ + request: oauthReqInfo, + userId: env.ALLOWED_EMAIL, + metadata: { label: env.ALLOWED_EMAIL }, + scope: oauthReqInfo.scope ? oauthReqInfo.scope : ["mcp:full"], + props: { + email: env.ALLOWED_EMAIL, + authenticatedAt: Date.now(), + }, + }); + + console.log("Authorization complete, redirecting to Claude"); + return Response.redirect(redirectTo, 302); + } catch (err) { + console.error("completeAuthorization error:", err); + return new Response("Authorization failed. Please try again.", { status: 500 }); + } + } + + if (url.pathname === "/") { + return new Response(JSON.stringify({ service: "Hindsight MCP OAuth Proxy" }), { + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response("Not Found", { status: 404 }); + }, +}; + +// --- Inner provider (not exported directly) --- +const provider = new OAuthProvider({ + apiRoute: "/mcp", + apiHandler: HindsightProxy, + defaultHandler: defaultHandler, + authorizeEndpoint: "/authorize", + tokenEndpoint: "/token", + clientRegistrationEndpoint: "/register", +}); + +// --- Wrapped export: restricts CORS + hardens metadata --- +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const origin = request.headers.get("Origin"); + const url = new URL(request.url); + + // Intercept OPTIONS: only allow preflight for allowlisted origins + if (request.method === "OPTIONS") { + if (origin && ALLOWED_ORIGINS.has(origin)) { + return new Response(null, { + status: 204, + headers: { "Content-Length": "0", ...corsHeaders(origin) }, + }); + } + return new Response(null, { status: 403 }); + } + + // Override metadata to advertise S256-only PKCE + if (url.pathname === "/.well-known/oauth-authorization-server") { + const response = await provider.fetch(request, env, ctx); + const metadata = await response.json() as Record; + metadata.code_challenge_methods_supported = ["S256"]; + const newResponse = new Response(JSON.stringify(metadata), { + headers: { "Content-Type": "application/json" }, + }); + return applyCors(newResponse, origin); + } + + // All other requests: pass to provider, then fix CORS + const response = await provider.fetch(request, env, ctx); + return applyCors(response, origin); + }, +}; diff --git a/hindsight-integrations/cloudflare-oauth-proxy/wrangler.toml b/hindsight-integrations/cloudflare-oauth-proxy/wrangler.toml new file mode 100644 index 00000000..092e8370 --- /dev/null +++ b/hindsight-integrations/cloudflare-oauth-proxy/wrangler.toml @@ -0,0 +1,21 @@ +name = "hindsight-oauth-proxy" +main = "src/index.ts" +compatibility_date = "2025-03-01" +workers_dev = true + +[[kv_namespaces]] +binding = "OAUTH_KV" +id = "your-kv-namespace-id" + +[vars] +HINDSIGHT_ORIGIN = "https://your-hindsight-origin.example.com" + +[[routes]] +pattern = "hindsight.yourdomain.com/*" +zone_name = "yourdomain.com" + +# Secrets (set via `npx wrangler secret put`): +# - SESSION_SECRET (password for the login page) +# - PROXY_SECRET (header value for WAF rule on origin) +# - HINDSIGHT_API_TOKEN (Bearer token for Hindsight API) +# - ALLOWED_EMAIL (your email, used as the OAuth user ID)