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) <noreply@anthropic.com>
This commit is contained in:
404sand808s 2026-04-10 12:52:19 -04:00 committed by GitHub
parent 3fc87e767c
commit aad07a141b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 382 additions and 0 deletions

View file

@ -0,0 +1,3 @@
node_modules/
dist/
.wrangler/

View file

@ -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.

View file

@ -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"
}
}

View file

@ -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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// --- CORS Allowlist ---
const ALLOWED_ORIGINS = new Set([
"https://claude.ai",
"https://www.claude.ai",
]);
function corsHeaders(origin: string): Record<string, string> {
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<Env> {
async fetch(request: Request): Promise<Response> {
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 '<!DOCTYPE html>' +
'<html><head><title>Hindsight MCP</title>' +
'<style>' +
'body{font-family:system-ui;background:#0a0a0a;color:#e0e0e0;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0}' +
'.card{background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:2rem;width:320px}' +
'h2{margin-top:0}' +
'input{width:100%;padding:10px;margin:8px 0;border:1px solid #444;border-radius:6px;background:#0a0a0a;color:#e0e0e0;box-sizing:border-box;font-size:16px}' +
'button{width:100%;padding:10px;margin-top:12px;border:none;border-radius:6px;background:#3b82f6;color:white;font-size:16px;cursor:pointer}' +
'button:hover{background:#2563eb}' +
'.error{color:#ef4444;font-size:14px}' +
'.info{color:#888;font-size:13px;margin-top:12px}' +
'</style></head><body>' +
'<div class="card">' +
'<h2>Hindsight MCP</h2>' +
'<p>Authorize Claude to access your memory.</p>' +
(error ? '<p class="error">' + escapeHtml(error) + '</p>' : '') +
'<form method="POST" action="/authorize">' +
'<input type="hidden" name="stateKey" value="' + escapeHtml(stateKey) + '" />' +
'<input type="password" name="password" placeholder="Password" autofocus required />' +
'<button type="submit">Authorize</button>' +
'</form>' +
'<p class="info">You only need to do this once per session.</p>' +
'</div></body></html>';
}
const defaultHandler = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
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<Response> {
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<string, unknown>;
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);
},
};

View file

@ -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)