Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.selfhost.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,11 @@ ACCESS_ALLOWED_EMAILS=
# one (ACCESS_ALLOWED_EMAILS is then ignored)
# TEAM_DOMAIN=https://your-team.cloudflareaccess.com
# POLICY_AUD=your-access-application-audience-tag

# Let one Cloudflare Access service token call the MCP inside the shared
# workspace. Client id of the token (the value sent as CF-Access-Client-Id)
# and an email for the service identity. Use a dedicated address such as
# automation@yourdomain.com, not a real user's: user emails are unique, so a
# real address here blocks that person's own login.
# MCP_SERVICE_TOKEN_CLIENT_ID=
# MCP_SERVICE_TOKEN_EMAIL=
2 changes: 2 additions & 0 deletions alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,8 @@ const dataEnv = {
BETTER_AUTH_SECRET: optionalSecret("BETTER_AUTH_SECRET"),
GOOGLE_CLIENT_ID: optionalVar("GOOGLE_CLIENT_ID"),
GOOGLE_CLIENT_SECRET: optionalSecret("GOOGLE_CLIENT_SECRET"),
MCP_SERVICE_TOKEN_CLIENT_ID: optionalVar("MCP_SERVICE_TOKEN_CLIENT_ID"),
MCP_SERVICE_TOKEN_EMAIL: optionalVar("MCP_SERVICE_TOKEN_EMAIL"),
OPENROUTER_API_KEY: optionalSecret("OPENROUTER_API_KEY"),
OPENROUTER_MODEL: optionalVar("OPENROUTER_MODEL"),
AUTUMN_SECRET_KEY: optionalSecret("AUTUMN_SECRET_KEY"),
Expand Down
17 changes: 17 additions & 0 deletions connect-claude-mcp.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Registers the self-hosted OpenSEO MCP in your own Claude Code (user scope)
# using the seo-automation service token. Prompts for the secret without
# echoing it. The secret is stored only in ~/.claude.json on this Mac.
set -euo pipefail
cd "$(dirname "$0")"
client_id=$(grep '^MCP_SERVICE_TOKEN_CLIENT_ID=' .env.selfhost | cut -d= -f2-)
subdomain=$(grep '^WORKERS_SUBDOMAIN=' .env.selfhost | cut -d= -f2-)
url="https://open-seo-selfhost.${subdomain}/mcp"
secret_file="$HOME/.config/openseo/cf-access-client-secret"
if [ -s "$secret_file" ]; then secret=$(cat "$secret_file"); else read -r -s -p "CF_ACCESS_CLIENT_SECRET: " secret; echo; fi
claude mcp remove --scope user openseo >/dev/null 2>&1 || true
claude mcp add --transport http --scope user openseo "$url" \
--header "CF-Access-Client-Id: $client_id" \
--header "CF-Access-Client-Secret: $secret"
unset secret
echo "Registered. Start 'claude' and run /mcp to confirm openseo shows as connected."
2 changes: 2 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ declare namespace Cloudflare {
BYPASS_EMAIL_VERIFICATION?: string;
TEAM_DOMAIN?: string;
POLICY_AUD?: string;
MCP_SERVICE_TOKEN_CLIENT_ID?: string;
MCP_SERVICE_TOKEN_EMAIL?: string;
POSTHOG_PUBLIC_KEY?: string;
POSTHOG_HOST?: string;
BETTER_AUTH_SECRET?: string;
Expand Down
97 changes: 97 additions & 0 deletions src/middleware/ensure-user/cloudflareAccess.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type * as Jose from "jose";
import { beforeEach, describe, expect, it, vi } from "vitest";

const { mockEnv, jwtVerify, resolveSharedWorkspaceContext } = vi.hoisted(
() => ({
mockEnv: {} as Record<string, string | undefined>,
jwtVerify: vi.fn(),
resolveSharedWorkspaceContext: vi.fn(
async (userId: string, userEmail: string) => ({
userId,
userEmail,
emailVerified: true,
organizationId: "org_shared",
role: "owner" as const,
}),
),
}),
);

vi.mock("cloudflare:workers", () => ({ env: mockEnv }));
vi.mock("jose", async (importOriginal) => {
const actual = await importOriginal<typeof Jose>();
return {
...actual,
createRemoteJWKSet: () => ({}),
jwtVerify: (...args: unknown[]) => jwtVerify(...args),
};
});
vi.mock("./delegated", () => ({ resolveSharedWorkspaceContext }));

import { resolveCloudflareAccessContext } from "./cloudflareAccess";

function headersWith(token: string) {
return new Headers({ "cf-access-jwt-assertion": token });
}

describe("resolveCloudflareAccessContext", () => {
beforeEach(() => {
for (const key of Object.keys(mockEnv)) delete mockEnv[key];
mockEnv.TEAM_DOMAIN = "https://martechs.cloudflareaccess.com";
mockEnv.POLICY_AUD = "aud-123";
jwtVerify.mockReset();
resolveSharedWorkspaceContext.mockClear();
});

it("resolves a user identity token by sub and email", async () => {
jwtVerify.mockResolvedValue({
payload: { sub: "user-1", email: "uday@martechs.io" },
});

const context = await resolveCloudflareAccessContext(headersWith("jwt"));

expect(context.userId).toBe("user-1");
expect(resolveSharedWorkspaceContext).toHaveBeenCalledWith(
"user-1",
"uday@martechs.io",
);
});

it("maps the configured service token to the shared workspace", async () => {
mockEnv.MCP_SERVICE_TOKEN_CLIENT_ID = "abc123.access";
mockEnv.MCP_SERVICE_TOKEN_EMAIL = "uday@martechs.io";
jwtVerify.mockResolvedValue({
payload: { sub: "", common_name: "abc123.access" },
});

const context = await resolveCloudflareAccessContext(headersWith("jwt"));

expect(context.userId).toBe("service:abc123.access");
expect(resolveSharedWorkspaceContext).toHaveBeenCalledWith(
"service:abc123.access",
"uday@martechs.io",
);
});

it("rejects a service token that is not the configured one", async () => {
mockEnv.MCP_SERVICE_TOKEN_CLIENT_ID = "abc123.access";
mockEnv.MCP_SERVICE_TOKEN_EMAIL = "uday@martechs.io";
jwtVerify.mockResolvedValue({
payload: { sub: "", common_name: "other.access" },
});

await expect(
resolveCloudflareAccessContext(headersWith("jwt")),
).rejects.toMatchObject({ code: "UNAUTHENTICATED" });
});

it("rejects any service token when none is configured", async () => {
jwtVerify.mockResolvedValue({
payload: { sub: "", common_name: "abc123.access" },
});

await expect(
resolveCloudflareAccessContext(headersWith("jwt")),
).rejects.toMatchObject({ code: "UNAUTHENTICATED" });
});
});
20 changes: 17 additions & 3 deletions src/middleware/ensure-user/cloudflareAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,23 @@ export async function resolveCloudflareAccessContext(
const userId = typeof payload.sub === "string" ? payload.sub : null;
const userEmail = typeof payload.email === "string" ? payload.email : null;

if (!userId || !userEmail) {
throw new AppError("UNAUTHENTICATED");
if (userId && userEmail) {
return resolveSharedWorkspaceContext(userId, userEmail);
}

return resolveSharedWorkspaceContext(userId, userEmail);
// Service tokens (Access "Service Auth" policies) carry no email: only a
// `common_name` equal to the token's client id. Exactly one configured token
// is allowed in, as its own user inside the shared workspace. The email must
// be dedicated to the token: user emails are unique, so reusing a person's
// address would block that person's own login.
const serviceClientId = env.MCP_SERVICE_TOKEN_CLIENT_ID?.trim();
const serviceEmail = env.MCP_SERVICE_TOKEN_EMAIL?.trim();
const commonName =
typeof payload.common_name === "string" ? payload.common_name : null;

if (serviceClientId && serviceEmail && commonName === serviceClientId) {
return resolveSharedWorkspaceContext(`service:${commonName}`, serviceEmail);
}

throw new AppError("UNAUTHENTICATED");
}
15 changes: 15 additions & 0 deletions store-api-token.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# One-time: stores a read-only Cloudflare API token so Claude can list the
# Access service tokens and application policies to debug the setup.
# Saved to ~/.config/openseo/cf-api-token, readable only by your user.
set -euo pipefail
dir="$HOME/.config/openseo"
mkdir -p "$dir" && chmod 700 "$dir"
read -r -s -p "Cloudflare API token (paste, then Enter): " tok; echo
tok="${tok//[$'\r\n\t ']/}"
if [ ${#tok} -lt 20 ]; then echo "Too short (${#tok}). Nothing saved."; exit 1; fi
printf '%s' "$tok" > "$dir/cf-api-token"
chmod 600 "$dir/cf-api-token"
status=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $tok" https://api.cloudflare.com/client/v4/user/tokens/verify)
unset tok
echo "Saved. Token verify HTTP $status (200 means it works)."
15 changes: 15 additions & 0 deletions store-secret.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# One-time: stores the seo-automation Access client secret in a file only your
# user can read (~/.config/openseo/cf-access-client-secret). The verify and
# connect scripts read it from there so nobody has to retype it.
set -euo pipefail
dir="$HOME/.config/openseo"
mkdir -p "$dir" && chmod 700 "$dir"
read -r -s -p "CF_ACCESS_CLIENT_SECRET (paste, then Enter): " secret; echo
secret="${secret//[$'\r\n\t ']/}"
if [ ${#secret} -lt 20 ]; then echo "That looks too short (${#secret} characters). Nothing saved."; exit 1; fi
echo "Received ${#secret} characters."
printf '%s' "$secret" > "$dir/cf-access-client-secret"
chmod 600 "$dir/cf-access-client-secret"
unset secret
echo "Saved ${#secret:-0} characters to $dir/cf-access-client-secret"
34 changes: 34 additions & 0 deletions verify-mcp.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Verifies that the seo-automation service token can reach the OpenSEO MCP.
# Prompts for the Access client secret without echoing it and never stores it.
set -euo pipefail
cd "$(dirname "$0")"
client_id=$(grep '^MCP_SERVICE_TOKEN_CLIENT_ID=' .env.selfhost | cut -d= -f2-)
subdomain=$(grep '^WORKERS_SUBDOMAIN=' .env.selfhost | cut -d= -f2-)
url="https://open-seo-selfhost.${subdomain}/mcp"
secret_file="$HOME/.config/openseo/cf-access-client-secret"
if [ -s "$secret_file" ]; then secret=$(cat "$secret_file"); else read -r -s -p "CF_ACCESS_CLIENT_SECRET: " secret; echo; fi
body='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"verify","version":"0"}}}'
health=$(curl -s -o /dev/null -w '%{http_code}' "https://open-seo-selfhost.${subdomain}/api/health" \
-H "CF-Access-Client-Id: $client_id" -H "CF-Access-Client-Secret: $secret")
echo "/api/health with service token -> HTTP $health (200 means the token and policy are right)"
mcp_get=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$url" \
-H "CF-Access-Client-Id: $client_id" -H "CF-Access-Client-Secret: $secret")
echo "GET /mcp with service token -> ${mcp_get:0:110}"
health_post=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -X POST "https://open-seo-selfhost.${subdomain}/api/health" \
-H "CF-Access-Client-Id: $client_id" -H "CF-Access-Client-Secret: $secret")
echo "POST /api/health with service token -> ${health_post:0:110}"
response=$(curl -s -w '\nHTTP %{http_code} %{redirect_url}' -X POST "$url" \
-H "CF-Access-Client-Id: $client_id" \
-H "CF-Access-Client-Secret: $secret" \
-H "content-type: application/json" \
-H "accept: application/json, text/event-stream" \
-d "$body")
unset secret
echo "$response" | tail -1 | cut -c1-120
if echo "$response" | grep -q '"serverInfo"'; then
echo "OK: the service token reaches the MCP and the server answered."
else
echo "FAILED. Response (first 300 chars):"
echo "$response" | head -c 300; echo
fi