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