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:
parent
3fc87e767c
commit
aad07a141b
5 changed files with 382 additions and 0 deletions
3
hindsight-integrations/cloudflare-oauth-proxy/.gitignore
vendored
Normal file
3
hindsight-integrations/cloudflare-oauth-proxy/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
94
hindsight-integrations/cloudflare-oauth-proxy/README.md
Normal file
94
hindsight-integrations/cloudflare-oauth-proxy/README.md
Normal 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.
|
||||
17
hindsight-integrations/cloudflare-oauth-proxy/package.json
Normal file
17
hindsight-integrations/cloudflare-oauth-proxy/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
247
hindsight-integrations/cloudflare-oauth-proxy/src/index.ts
Normal file
247
hindsight-integrations/cloudflare-oauth-proxy/src/index.ts
Normal 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, "&")
|
||||
.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<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);
|
||||
},
|
||||
};
|
||||
21
hindsight-integrations/cloudflare-oauth-proxy/wrangler.toml
Normal file
21
hindsight-integrations/cloudflare-oauth-proxy/wrangler.toml
Normal 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)
|
||||
Loading…
Reference in a new issue