From aeb34fafdf1468417d9f582bb8094e528bff29a8 Mon Sep 17 00:00:00 2001 From: David Rossiter <123392630+davidwrossiter@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:30:11 +0100 Subject: [PATCH] preserve cloudflare access config across deploys --- apps/host-cloudflare/README.md | 22 +++--- apps/host-cloudflare/package.json | 1 + apps/host-cloudflare/scripts/deploy.sh | 18 +++-- .../src/auth/cloudflare-access.ts | 5 +- apps/host-cloudflare/src/config.test.ts | 74 +++++++++++++++++++ apps/host-cloudflare/src/config.ts | 49 ++++++++++-- .../src/worker.e2e.node.test.ts | 58 ++++++++++++--- apps/host-cloudflare/src/worker.ts | 20 ++++- apps/host-cloudflare/wrangler.jsonc | 16 ++-- bun.lock | 1 + 10 files changed, 224 insertions(+), 40 deletions(-) create mode 100644 apps/host-cloudflare/src/config.test.ts diff --git a/apps/host-cloudflare/README.md b/apps/host-cloudflare/README.md index 1770dbbe8..970057028 100644 --- a/apps/host-cloudflare/README.md +++ b/apps/host-cloudflare/README.md @@ -36,14 +36,14 @@ bunx wrangler login bun run deploy:setup # apps/host-cloudflare — provisions D1 + secret + deploys ``` -`deploy:setup` (scripts/deploy.sh) is idempotent: it creates/reuses the -`executor` D1 database, writes its id into `wrangler.jsonc`, generates + -uploads `EXECUTOR_SECRET_KEY`, and deploys. It then prints the one manual step. +`deploy:setup` (scripts/deploy.sh) is idempotent. It creates or reuses the +`executor` D1 database, writes its id into `wrangler.jsonc`, generates and +uploads `EXECUTOR_SECRET_KEY`, then deploys. It then prints the one manual step. ### The one manual step — Cloudflare Access -The Worker returns 401 until it's behind a Cloudflare Access application. In the -Zero Trust dashboard: +After the first deploy, API and MCP requests return 503 and name the missing +Access variables until configuration is complete. In the Zero Trust dashboard: 1. **Access → Applications → Add an application → Self-hosted** 2. Application domain: `executor-cloudflare..workers.dev` @@ -52,13 +52,17 @@ Zero Trust dashboard: ```bash bunx wrangler deploy \ --var ACCESS_AUD: \ - --var ACCESS_TEAM_DOMAIN:.cloudflareaccess.com + --var ACCESS_TEAM_DOMAIN:.cloudflareaccess.com \ + --var ADMIN_EMAILS: ``` - (or set them in `wrangler.jsonc` and redeploy) Now visiting the Worker prompts an Access login; the Worker validates the issued -JWT on every request. MCP clients present an Access JWT or -`Cf-Access-Client-Id`/`-Secret` service-token headers. +JWT on every request. Unauthenticated requests return 401. MCP clients present +an Access JWT or `Cf-Access-Client-Id`/`-Secret` service-token headers. + +The Access values are live Worker variables, not values in `wrangler.jsonc`. +Wrangler's `keep_vars` option preserves them during later code deploys. Run the +command above again whenever you need to change them. ## Local development diff --git a/apps/host-cloudflare/package.json b/apps/host-cloudflare/package.json index d7159620d..cab97d367 100644 --- a/apps/host-cloudflare/package.json +++ b/apps/host-cloudflare/package.json @@ -53,6 +53,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", + "jsonc-parser": "^3.3.1", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:", diff --git a/apps/host-cloudflare/scripts/deploy.sh b/apps/host-cloudflare/scripts/deploy.sh index 4d8a2857e..130bd5507 100755 --- a/apps/host-cloudflare/scripts/deploy.sh +++ b/apps/host-cloudflare/scripts/deploy.sh @@ -7,7 +7,7 @@ # wrangler.jsonc # 3. generates + uploads EXECUTOR_SECRET_KEY (the at-rest secret key) if unset # 4. deploys the Worker -# 5. prints the single manual step: the Cloudflare Access application +# 5. prints the single manual step: configure the Cloudflare Access application # # Idempotent — safe to re-run. Run from anywhere: # bash apps/host-cloudflare/scripts/deploy.sh @@ -71,18 +71,22 @@ cat <<'NEXT' ==> One manual step left: turn on Cloudflare Access (the auth layer) - The Worker is deployed but every request returns 401 until you put it behind - a Cloudflare Access application. In the Zero Trust dashboard: + The Worker is deployed but is not ready to serve requests until you configure + a Cloudflare Access application. API and MCP requests return 503 and name the + missing variables until configuration is complete. In the Zero Trust + dashboard: 1. Access -> Applications -> Add an application -> Self-hosted 2. Application domain: executor-cloudflare..workers.dev 3. Add an Access policy (e.g. "Emails ending in @yourcompany.com") 4. After saving, copy the Application Audience (AUD) tag, then set: - bunx wrangler deploy --var ACCESS_AUD: \ - --var ACCESS_TEAM_DOMAIN:.cloudflareaccess.com - (or edit the vars in wrangler.jsonc and redeploy) + bunx wrangler deploy --var ACCESS_AUD: \ + --var ACCESS_TEAM_DOMAIN:.cloudflareaccess.com \ + --var ADMIN_EMAILS: - That's it — visiting the Worker URL now prompts a Cloudflare Access login, + Wrangler preserves these live variables during later code deploys. + + That's it. Visiting the Worker URL now prompts a Cloudflare Access login, and the Worker validates the issued JWT on every request. NEXT diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.ts b/apps/host-cloudflare/src/auth/cloudflare-access.ts index 178f8818f..3a206bb3b 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.ts @@ -60,7 +60,9 @@ export const principalFromAccessClaims = ( export const makeAccessVerifier = (config: CloudflareConfig) => { const issuer = `https://${config.accessTeamDomain}`; // Cached, lazily-fetched team signing keys; jose handles rotation + caching. - const jwks = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`)); + const jwks = config.enableDevAuth + ? null + : createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`)); // Dev/single-user escape hatch: bypass Access entirely, every request is a // fixed admin. Only when explicitly enabled (and the instance is otherwise @@ -79,6 +81,7 @@ export const makeAccessVerifier = (config: CloudflareConfig) => { const verify = (request: Request): Effect.Effect => Effect.gen(function* () { if (config.enableDevAuth) return devPrincipal; + if (!jwks) return null; const token = request.headers.get("Cf-Access-Jwt-Assertion"); if (!token) return null; diff --git a/apps/host-cloudflare/src/config.test.ts b/apps/host-cloudflare/src/config.test.ts new file mode 100644 index 000000000..39aab1e7d --- /dev/null +++ b/apps/host-cloudflare/src/config.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "@effect/vitest"; +import { parse } from "jsonc-parser"; + +import { loadConfig } from "./config"; + +type ConfigEnv = Parameters[0]; + +const makeEnv = (overrides: Partial = {}): ConfigEnv => ({ + EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", + VITE_PUBLIC_SITE_URL: "https://executor.example.com", + ...overrides, +}); + +describe("loadConfig", () => { + it("rejects missing Cloudflare Access configuration outside local development", () => { + expect(() => loadConfig(makeEnv())).toThrowError( + "Cloudflare Access is not configured. Set ACCESS_TEAM_DOMAIN and ACCESS_AUD before serving requests.", + ); + }); + + it("rejects the repository's former team-domain placeholder", () => { + expect(() => + loadConfig( + makeEnv({ + ACCESS_TEAM_DOMAIN: "your-team.cloudflareaccess.com", + ACCESS_AUD: "aud-tag", + }), + ), + ).toThrowError( + "Cloudflare Access is not configured. Set ACCESS_TEAM_DOMAIN before serving requests.", + ); + }); + + it("allows local development to bypass Cloudflare Access", () => { + expect(loadConfig(makeEnv({ ENABLE_DEV_AUTH: "true" }))).toMatchObject({ + accessTeamDomain: "", + accessAud: "", + enableDevAuth: true, + }); + }); + + it("normalises configured Access values without requiring an administrator", () => { + expect( + loadConfig( + makeEnv({ + ACCESS_TEAM_DOMAIN: "https://Team.cloudflareaccess.com/", + ACCESS_AUD: " aud-tag ", + }), + ), + ).toMatchObject({ + accessTeamDomain: "Team.cloudflareaccess.com", + accessAud: "aud-tag", + adminEmails: [], + enableDevAuth: false, + }); + }); +}); + +describe("Cloudflare deployment configuration", () => { + it("preserves operator-managed Access variables across deploys", () => { + const config = parse(readFileSync(new URL("../wrangler.jsonc", import.meta.url), "utf8")) as { + readonly keep_vars?: boolean; + readonly vars?: Readonly>; + }; + + expect(config.keep_vars).toBe(true); + expect(config.vars).not.toHaveProperty("ACCESS_TEAM_DOMAIN"); + expect(config.vars).not.toHaveProperty("ACCESS_AUD"); + expect(config.vars).not.toHaveProperty("ADMIN_EMAILS"); + expect(config.vars).toHaveProperty("ENABLE_DEV_AUTH", "false"); + }); +}); diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index bded06965..5085ce6e3 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -27,9 +27,9 @@ export interface CloudflareEnv { readonly MCP_SESSION: DurableObjectNamespace; readonly MCP_EXECUTION_OWNER?: DurableObjectNamespace; /** Zero Trust team domain, e.g. `your-team.cloudflareaccess.com`. */ - readonly ACCESS_TEAM_DOMAIN: string; + readonly ACCESS_TEAM_DOMAIN?: string; /** The Access application's AUD tag (the JWT audience to verify). */ - readonly ACCESS_AUD: string; + readonly ACCESS_AUD?: string; /** Claim holding the display name (default `name`). */ readonly ACCESS_NAME_CLAIM?: string; /** Claim holding the user's groups (default `groups`). */ @@ -72,12 +72,44 @@ export interface CloudflareConfig { readonly enableDevAuth: boolean; } +type CloudflareConfigEnv = Omit< + CloudflareEnv, + "DB" | "BLOBS" | "MCP_SESSION" | "MCP_EXECUTION_OWNER" +>; + +type CloudflareAccessEnv = Pick< + CloudflareConfigEnv, + "ACCESS_TEAM_DOMAIN" | "ACCESS_AUD" | "ENABLE_DEV_AUTH" +>; + const splitLower = (value: string | undefined): readonly string[] => (value ?? "") .split(",") .map((part) => part.trim().toLowerCase()) .filter((part) => part.length > 0); +const normalizeAccessTeamDomain = (value: string | undefined): string => + (value ?? "") + .trim() + .replace(/^https?:\/\//, "") + .replace(/\/+$/, ""); + +export const missingCloudflareAccessVars = (env: CloudflareAccessEnv): readonly string[] => { + if (env.ENABLE_DEV_AUTH === "true") return []; + const accessTeamDomain = normalizeAccessTeamDomain(env.ACCESS_TEAM_DOMAIN); + const accessAud = (env.ACCESS_AUD ?? "").trim(); + return [ + ...(accessTeamDomain.length === 0 || + accessTeamDomain.toLowerCase() === "your-team.cloudflareaccess.com" + ? ["ACCESS_TEAM_DOMAIN"] + : []), + ...(accessAud.length === 0 ? ["ACCESS_AUD"] : []), + ]; +}; + +export const cloudflareAccessConfigErrorMessage = (missingVars: readonly string[]): string => + `Cloudflare Access is not configured. Set ${missingVars.join(" and ")} before serving requests.`; + // The org slug doubles as a URL segment (`//policies`), so an // operator-set value must fit the shared grammar and avoid reserved root // segments — a colliding slug would shadow real routes (notably /api, /mcp, @@ -93,7 +125,7 @@ const resolveOrgSlug = (value: string | undefined): string => { return value; }; -export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { +export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => { const secretKey = env.EXECUTOR_SECRET_KEY?.trim(); if (!secretKey || secretKey.length < 16) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the Worker must not boot without the at-rest secret key @@ -102,6 +134,13 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { ); } const enableDevAuth = env.ENABLE_DEV_AUTH === "true"; + const accessTeamDomain = normalizeAccessTeamDomain(env.ACCESS_TEAM_DOMAIN); + const accessAud = (env.ACCESS_AUD ?? "").trim(); + const missingAccessVars = missingCloudflareAccessVars(env); + if (missingAccessVars.length > 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: production must fail closed without a valid Access verifier + throw new Error(cloudflareAccessConfigErrorMessage(missingAccessVars)); + } const webBaseUrl = resolvePublicOrigin({ explicit: env.VITE_PUBLIC_SITE_URL, env: {} }); if (!webBaseUrl && !enableDevAuth && !warnedNoCloudflareOrigin) { warnedNoCloudflareOrigin = true; @@ -113,8 +152,8 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { ); } return { - accessTeamDomain: env.ACCESS_TEAM_DOMAIN.replace(/^https?:\/\//, "").replace(/\/+$/, ""), - accessAud: env.ACCESS_AUD, + accessTeamDomain, + accessAud, accessNameClaim: env.ACCESS_NAME_CLAIM ?? "name", accessGroupsClaim: env.ACCESS_GROUPS_CLAIM ?? "groups", adminEmails: splitLower(env.ADMIN_EMAILS), diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index 84c6fb011..99c2ecc3d 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -19,6 +19,18 @@ import { microsoftCatalog } from "@executor-js/plugin-openapi/providers/microsof const dir = fileURLToPath(new URL(".", import.meta.url)); const runId = randomUUID().slice(0, 8); +const ensureStaticAssets = () => { + // CI runs from a fresh checkout with no `vite build`, so `./dist` (the SPA + // assets dir wrangler.jsonc points `assets.directory` at) is absent and + // `unstable_dev`'s assets validation aborts boot. These tests drive the + // API/MCP surface (all `run_worker_first` paths), not the SPA. + const distIndex = resolve(dir, "../dist/index.html"); + if (!existsSync(distIndex)) { + mkdirSync(resolve(dir, "../dist"), { recursive: true }); + writeFileSync(distIndex, "executor"); + } +}; + // Inline spec (no network); registers one tool, exercising the D1 write path. const SPEC = JSON.stringify({ openapi: "3.0.0", @@ -77,21 +89,13 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { let worker: Unstable_DevWorker; beforeAll(async () => { - // CI runs from a fresh checkout with no `vite build`, so `./dist` (the SPA - // assets dir wrangler.jsonc points `assets.directory` at) is absent and - // `unstable_dev`'s assets validation aborts boot. This e2e drives the - // API/MCP surface (all `run_worker_first` paths), not the SPA, so a minimal - // placeholder index.html satisfies the validation without a real build. - const distIndex = resolve(dir, "../dist/index.html"); - if (!existsSync(distIndex)) { - mkdirSync(resolve(dir, "../dist"), { recursive: true }); - writeFileSync(distIndex, "executor"); - } + ensureStaticAssets(); worker = await unstable_dev(resolve(dir, "worker.ts"), { config: resolve(dir, "../wrangler.jsonc"), ip: "127.0.0.1", local: true, + persist: false, experimental: { disableExperimentalWarning: true }, vars: { EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", @@ -474,3 +478,37 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { expect(resumed.result?.structuredContent?.status).toBe("completed"); }, 60_000); }); + +describe("cloudflare host configuration errors", () => { + let worker: Unstable_DevWorker; + + beforeAll(async () => { + ensureStaticAssets(); + worker = await unstable_dev(resolve(dir, "worker.ts"), { + config: resolve(dir, "../wrangler.jsonc"), + ip: "127.0.0.1", + local: true, + persist: false, + experimental: { disableExperimentalWarning: true }, + vars: { + EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", + }, + }); + }, 120_000); + + afterAll(async () => { + await worker?.stop(); + }); + + it("returns an actionable response when Cloudflare Access is not configured", async () => { + for (const path of ["/api/account/me", "/mcp"]) { + const response = await worker.fetch(path); + + expect(response.status).toBe(503); + expect(response.headers.get("cache-control")).toBe("no-store"); + await expect(response.text()).resolves.toBe( + "Cloudflare Access is not configured. Set ACCESS_TEAM_DOMAIN and ACCESS_AUD before serving requests.\n", + ); + } + }); +}); diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts index 74a1be074..ac9c1b30b 100644 --- a/apps/host-cloudflare/src/worker.ts +++ b/apps/host-cloudflare/src/worker.ts @@ -1,5 +1,9 @@ import { makeCloudflareApp } from "./app"; -import type { CloudflareEnv } from "./config"; +import { + cloudflareAccessConfigErrorMessage, + missingCloudflareAccessVars, + type CloudflareEnv, +} from "./config"; // The MCP Durable Object classes, bound in wrangler.jsonc. They must be exported // at the Worker entry module scope for the runtime to find them. @@ -27,8 +31,22 @@ const resolveHandler = (env: CloudflareEnv) => { return handlerPromise; }; +const accessConfigErrorResponse = (missingVars: readonly string[]): Response => + new Response(`${cloudflareAccessConfigErrorMessage(missingVars)}\n`, { + status: 503, + headers: { + "cache-control": "no-store", + "content-type": "text/plain; charset=utf-8", + }, + }); + export default { fetch: async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise => { + const missingAccessVars = missingCloudflareAccessVars(env); + if (missingAccessVars.length > 0) { + return accessConfigErrorResponse(missingAccessVars); + } + const serve = await resolveHandler(env); if (new URL(request.url).pathname === "/mcp") { return serve.mcp(request, env, ctx); diff --git a/apps/host-cloudflare/wrangler.jsonc b/apps/host-cloudflare/wrangler.jsonc index 6b3a12b7f..1580c8c3d 100644 --- a/apps/host-cloudflare/wrangler.jsonc +++ b/apps/host-cloudflare/wrangler.jsonc @@ -4,6 +4,9 @@ "compatibility_date": "2025-04-01", "compatibility_flags": ["nodejs_compat"], "main": "src/worker.ts", + // Access configuration is set per installation after the first deploy. + // Preserve those live bindings when this source config does not declare them. + "keep_vars": true, "observability": { "enabled": true }, // The web UI (Workers Static Assets) — the shared multiplayer SPA built by // `vite build` into ./dist. `single-page-application` serves index.html for @@ -49,17 +52,16 @@ { "tag": "v1", "new_sqlite_classes": ["McpSessionDO"] }, { "tag": "v2", "new_sqlite_classes": ["McpExecutionOwnerDirectoryDO"] }, ], - // Cloudflare Access is the entire auth layer: the Worker validates the - // Cf-Access-Jwt-Assertion JWT against the team JWKS. Set these to your Zero - // Trust team domain + the Access application's AUD tag. EXECUTOR_SECRET_KEY - // (the at-rest secret-encryption key) is a SECRET — set it with + // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD, + // and ADMIN_EMAILS are installation-specific live vars, set after the first + // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (the at-rest + // secret-encryption key) is a SECRET, set it with // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars. "vars": { - "ACCESS_TEAM_DOMAIN": "your-team.cloudflareaccess.com", - "ACCESS_AUD": "", "ACCESS_NAME_CLAIM": "name", "ACCESS_GROUPS_CLAIM": "groups", - "ADMIN_EMAILS": "", + // Never preserve a production dev-auth override through keep_vars. + "ENABLE_DEV_AUTH": "false", "SELF_HOSTED_ORG_ID": "default", "SELF_HOSTED_ORG_NAME": "Default", // VITE_PUBLIC_SITE_URL is intentionally unset: with no static URL the worker diff --git a/bun.lock b/bun.lock index 657f7fc65..c46f8d9ba 100644 --- a/bun.lock +++ b/bun.lock @@ -206,6 +206,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", + "jsonc-parser": "^3.3.1", "typescript": "catalog:", "vite": "catalog:", "vitest": "catalog:",