From b4c6174b89dfebd48463e1389374997d4684eab7 Mon Sep 17 00:00:00 2001 From: olegberman Date: Tue, 28 Jul 2026 18:03:50 -0400 Subject: [PATCH] feat: support customer-managed OAuth --- .github/workflows/release.yml | 7 + .github/workflows/staging-e2e.yml | 4 + README.md | 1 + app/features/domains/domain-settings.tsx | 2 +- app/features/updates/update-settings.tsx | 2 +- config/product.json | 5 + docs/customer-managed-oauth.md | 93 +++++++++++++ docs/deployment.md | 3 + docs/updates.md | 6 +- scripts/hqbase/cli.mjs | 14 ++ scripts/hqbase/config.mjs | 19 ++- scripts/hqbase/destroy.mjs | 4 +- scripts/hqbase/install.mjs | 50 ++++++- scripts/hqbase/oauth.mjs | 42 ++++++ scripts/release/deploy.mjs | 9 ++ test/e2e/staging/app-shell-smoke.spec.ts | 23 ++++ test/unit/scripts/deploy.test.mjs | 18 +++ test/unit/scripts/install.test.mjs | 80 ++++++++++- .../worker/features/cloudflare/oauth.test.ts | 94 ++++++++++++- .../setup/cloudflare-setup-api.test.ts | 2 +- worker-configuration.d.ts | 2 +- worker/features/cloudflare/oauth-config.ts | 126 ++++++++++++++++++ worker/features/cloudflare/oauth.ts | 57 ++++---- worker/features/setup/cloudflare-api.ts | 2 +- worker/lib/env.ts | 3 +- wrangler.jsonc | 7 +- 26 files changed, 623 insertions(+), 52 deletions(-) create mode 100644 docs/customer-managed-oauth.md create mode 100644 scripts/hqbase/oauth.mjs create mode 100644 worker/features/cloudflare/oauth-config.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f476d39..b8e6a6a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -144,6 +144,7 @@ jobs: HQBASE_STAGING_EMAIL_DOMAIN: ${{ secrets.HQBASE_E2E_EMAIL_DOMAIN }} HQBASE_STAGING_OWNER_EMAIL: owner@${{ secrets.HQBASE_E2E_EMAIL_DOMAIN }} HQBASE_STAGING_OWNER_PASSWORD: ${{ secrets.HQBASE_E2E_OWNER_PASSWORD }} + HQBASE_STAGING_OAUTH_CLIENT_ID: ${{ vars.HQBASE_E2E_OAUTH_CLIENT_ID }} HQBASE_STAGING_SENDER: hello@${{ secrets.HQBASE_E2E_EMAIL_DOMAIN }} HQBASE_STAGING_URL: https://${{ secrets.HQBASE_E2E_APP_HOSTNAME }} DEPLOYMENT_NAME: release-${{ github.run_id }} @@ -220,6 +221,12 @@ jobs: run: pnpm test:e2e:staging:lifecycle - name: Apply the exact signed candidate run: | + pnpm hqbase oauth \ + --name "$DEPLOYMENT_NAME" \ + --mode customer \ + --auth-url "$HQBASE_STAGING_URL" \ + --client-id "$HQBASE_STAGING_OAUTH_CLIENT_ID" \ + --skip-deploy config=".hqbase/deployments/$DEPLOYMENT_NAME/wrangler.jsonc" HQBASE_AUTH_SECRET="$HQBASE_STAGING_AUTH_SECRET" \ HQBASE_EXPECTED_RELEASE_VERSION="$CANDIDATE_VERSION" \ diff --git a/.github/workflows/staging-e2e.yml b/.github/workflows/staging-e2e.yml index c35b451..349ece2 100644 --- a/.github/workflows/staging-e2e.yml +++ b/.github/workflows/staging-e2e.yml @@ -30,6 +30,7 @@ jobs: HQBASE_STAGING_EMAIL_DOMAIN: ${{ secrets.HQBASE_E2E_EMAIL_DOMAIN }} HQBASE_STAGING_OWNER_EMAIL: owner@${{ secrets.HQBASE_E2E_EMAIL_DOMAIN }} HQBASE_STAGING_OWNER_PASSWORD: ${{ secrets.HQBASE_E2E_OWNER_PASSWORD }} + HQBASE_STAGING_OAUTH_CLIENT_ID: ${{ vars.HQBASE_E2E_OAUTH_CLIENT_ID }} HQBASE_STAGING_SENDER: hello@${{ secrets.HQBASE_E2E_EMAIL_DOMAIN }} HQBASE_STAGING_URL: https://${{ secrets.HQBASE_E2E_APP_HOSTNAME }} DEPLOYMENT_NAME: e2e-${{ github.run_id }} @@ -60,6 +61,9 @@ jobs: --domain "$HQBASE_STAGING_EMAIL_DOMAIN" --no-email --app-domain "${HQBASE_STAGING_URL#https://}" + --auth-url "$HQBASE_STAGING_URL" + --oauth-mode customer + --oauth-client-id "$HQBASE_STAGING_OAUTH_CLIENT_ID" --skip-build --skip-deploy - name: Deploy reviewed source candidate diff --git a/README.md b/README.md index 3b1d22e..0b8d497 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Run `pnpm cf:typegen` after changing `wrangler.jsonc`. ## Operations - `docs/access-control.md`: roles, mailbox grants, and enforcement. +- `docs/customer-managed-oauth.md`: private Cloudflare OAuth clients for restricted organizations. - `docs/mcp.md`: MCP discovery, OAuth, scopes, and mailbox authorization. - `docs/operations.md`: doctor, backup, restore, queues, retention, and incident order. - `docs/updates.md`: public signed releases, update checks, verification, and recovery. diff --git a/app/features/domains/domain-settings.tsx b/app/features/domains/domain-settings.tsx index bbc49dd..836b585 100644 --- a/app/features/domains/domain-settings.tsx +++ b/app/features/domains/domain-settings.tsx @@ -224,5 +224,5 @@ function readPendingOperation(): PendingCloudflareOperation | null { function oauthErrorMessage(result: string): string { if (result === "denied") return "Cloudflare authorization was cancelled."; if (result === "invalid") return "Cloudflare authorization expired. Please try again."; - return "Cloudflare could not authorize this change. If your organization blocks HQBase, ask a Cloudflare administrator to allow the OAuth application."; + return "Cloudflare could not authorize this change. Ask a Cloudflare administrator to allow HQBase or configure customer-managed OAuth from the deployment guide."; } diff --git a/app/features/updates/update-settings.tsx b/app/features/updates/update-settings.tsx index a1f3f46..34defa5 100644 --- a/app/features/updates/update-settings.tsx +++ b/app/features/updates/update-settings.tsx @@ -164,7 +164,7 @@ export function UpdateSettings({ function oauthErrorMessage(result: string): string { if (result === "denied") return "Cloudflare authorization was cancelled."; if (result === "invalid") return "Cloudflare authorization expired. Please try again."; - return "Cloudflare could not authorize the update. If your organization blocks HQBase, ask a Cloudflare administrator to allow the OAuth application."; + return "Cloudflare could not authorize the update. Ask a Cloudflare administrator to allow HQBase or configure customer-managed OAuth from the deployment guide."; } function Version({ label, value }: { label: string; value: string }): React.ReactElement { diff --git a/config/product.json b/config/product.json index f3a1c6d..16bd023 100644 --- a/config/product.json +++ b/config/product.json @@ -2,6 +2,11 @@ "cloudflareOAuthClientId": "1c413f324b518b452096929b847e6703", "cloudflareOAuthRedirectUri": "https://auth.hqbase.io/oauth/callback", "cloudflareOAuthRelayUrl": "https://auth.hqbase.io", + "cloudflareOAuthScopes": { + "setup": "workers-scripts.write zone.read zone-settings.write email-routing-rule.write email-sending.write", + "domains": "workers-scripts.write zone.read zone-settings.write email-routing-rule.write email-sending.write", + "updates": "workers-scripts.write workers-ci.write zone.read" + }, "releaseManifestUrl": "https://github.com/HQBase/hqbase/releases/latest/download/stable.json", "releasePublicKey": "MCowBQYDK2VwAyEAsVwKniCvpHDwbbnjTPP0SuIIG97cRL+iFBQvay9OrU4=" } diff --git a/docs/customer-managed-oauth.md b/docs/customer-managed-oauth.md new file mode 100644 index 0000000..364dc74 --- /dev/null +++ b/docs/customer-managed-oauth.md @@ -0,0 +1,93 @@ +# Customer-managed Cloudflare OAuth + +HQBase uses the verified public HQBase OAuth client by default. If a Cloudflare administrator +blocks public OAuth applications, the administrator can register a private OAuth client in the +same Cloudflare account as the HQBase deployment. HQBase still uses Authorization Code with PKCE +and never receives a client secret or API token. + +## Register the client + +In Cloudflare, create an OAuth client with: + +- Grant type: `authorization_code`. +- Response type: `code`. +- Token endpoint authentication method: `none`. +- PKCE method: `S256`. +- Visibility: private. + +Register these three exact redirect URLs, replacing `https://mail.example.com` with the canonical +HTTPS origin used to open HQBase: + +```text +https://mail.example.com/api/setup/cloudflare/oauth/callback +https://mail.example.com/api/domains/cloudflare/oauth/callback +https://mail.example.com/api/updates/cloudflare/oauth/callback +``` + +Allow this union of HQBase operation scopes: + +```text +workers-scripts.write +workers-ci.write +zone.read +zone-settings.write +email-routing-rule.write +email-sending.write +``` + +HQBase requests only the subset required by the current setup, domain, or update operation. + +## Configure a new deployment + +The client ID is public configuration. Do not create or provide a client secret. + +```sh +pnpm hqbase install \ + --name production \ + --app-domain mail.example.com \ + --auth-url https://mail.example.com \ + --oauth-mode customer \ + --oauth-client-id YOUR_CLIENT_ID +``` + +The generated deployment manifest records the client ID and mode for recovery. The generated +Wrangler configuration stores them as non-secret variables in the customer account. + +## Switch an existing deployment + +Use the named deployment operator so the manifest, generated Wrangler configuration, and deployed +Worker remain aligned: + +```sh +pnpm hqbase oauth \ + --name production \ + --mode customer \ + --auth-url https://mail.example.com \ + --client-id YOUR_CLIENT_ID +``` + +Validate without writing or deploying: + +```sh +pnpm hqbase oauth \ + --name production \ + --mode customer \ + --auth-url https://mail.example.com \ + --client-id YOUR_CLIENT_ID \ + --dry-run +``` + +Return to the verified public HQBase client: + +```sh +pnpm hqbase oauth --name production --mode official +``` + +## Security properties + +- The configured canonical origin, not the incoming request Host header, determines every direct + callback. +- The customer Worker generates the PKCE verifier, exchanges the code, encrypts the temporary + grant in an HTTP-only cookie, and revokes it after the operation. +- OAuth callback invocation URLs are not persisted in Workers Logs. +- HQBase never stores the Cloudflare access grant in D1 or R2. diff --git a/docs/deployment.md b/docs/deployment.md index 4843fe2..352c2db 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -23,6 +23,9 @@ already exist. Operators may supply it explicitly for controlled automation. Cloudflare OAuth client configuration is compiled public product configuration. Temporary OAuth grants used for setup or updates are encrypted, scoped to the operation, and revoked after use. +Organizations that block public OAuth applications can use a private customer-managed client +without providing an API token or client secret. See +[customer-managed-oauth.md](customer-managed-oauth.md). ## Default resources diff --git a/docs/updates.md b/docs/updates.md index 6d4dc51..18ee67b 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -16,8 +16,10 @@ commands. Both rollbacks remain deliberate operator actions because a database r writes made after the bookmark. Starting an update from the app requires a short-lived Cloudflare OAuth grant. HQBase revokes the -grant after use. The browser talks directly to Cloudflare, and the verified release comes directly -from the canonical public repository. +grant after use. The verified public client uses the public HQBase relay; customer-managed clients +redirect directly between the customer Worker and Cloudflare. In both modes the customer Worker +performs the token exchange, and the verified release comes directly from the canonical public +repository. Release artifacts, manifests, checksums, and release notes are public GitHub Release assets owned by the canonical repository. diff --git a/scripts/hqbase/cli.mjs b/scripts/hqbase/cli.mjs index 2a7f44b..c73c238 100644 --- a/scripts/hqbase/cli.mjs +++ b/scripts/hqbase/cli.mjs @@ -6,6 +6,7 @@ import { updateDeployButton } from "./button.mjs"; import { destroy } from "./destroy.mjs"; import { doctor } from "./doctor.mjs"; import { install } from "./install.mjs"; +import { configureOAuth } from "./oauth.mjs"; import { printPostDeploy } from "./postdeploy.mjs"; import { reset } from "./reset.mjs"; import { restore } from "./restore.mjs"; @@ -26,6 +27,9 @@ try { case "doctor": doctor(flags); break; + case "oauth": + configureOAuth(flags); + break; case "backup": backup(flags); break; @@ -59,6 +63,7 @@ function printHelp() { Usage: pnpm hqbase button --repo-url https://github.com/OWNER/REPO pnpm hqbase install --name dev-01 [--domain example.com] + pnpm hqbase oauth --name dev-01 --mode official|customer pnpm hqbase doctor --name dev-01 pnpm hqbase backup --name dev-01 [--output backup.json] pnpm hqbase restore --name dev-01 --backup backup.json --yes @@ -76,10 +81,19 @@ Install options: --no-sending Skip Email Sending enablement. --app-domain Attach a custom Worker domain in the generated config. --auth-url Set BETTER_AUTH_URL explicitly. Usually unnecessary. + --oauth-mode Use official (default) or customer-managed OAuth. + --oauth-client-id Customer OAuth client ID. Requires --oauth-mode customer and --auth-url. HQBASE_AUTH_SECRET Preserve an existing Better Auth secret without exposing it in argv. --auth-secret Compatibility fallback. Prefer HQBASE_AUTH_SECRET. --skip-build Skip pnpm build. --skip-deploy Create resources/config/migrations without deploying Worker. --dry-run Print commands without mutating Cloudflare. + +OAuth options: + --mode Use official or customer-managed OAuth. + --client-id Customer OAuth client ID. Required for customer mode. + --auth-url Exact canonical HTTPS HQBase origin. Required for customer mode. + --skip-deploy Validate and write local deployment configuration without deploying. + --dry-run Validate without writing or deploying. `); } diff --git a/scripts/hqbase/config.mjs b/scripts/hqbase/config.mjs index 5cb4d8d..9f3aaff 100644 --- a/scripts/hqbase/config.mjs +++ b/scripts/hqbase/config.mjs @@ -11,11 +11,17 @@ export function writeWranglerConfig(manifest, options = {}) { return; } + const config = createWranglerConfig(manifest); + fs.writeFileSync(configPath(manifest.name), `${JSON.stringify(config, null, 2)}\n`); +} + +export function createWranglerConfig(manifest) { + const cloudflareOAuth = manifest.cloudflareOAuth ?? { mode: "official" }; const config = { $schema: `${rootFromDeployment}/node_modules/wrangler/config-schema.json`, name: manifest.worker.name, main: `${rootFromDeployment}/worker/index.ts`, - compatibility_date: "2026-07-11", + compatibility_date: "2026-07-28", compatibility_flags: ["nodejs_compat"], assets: { directory: `${rootFromDeployment}/dist`, @@ -24,7 +30,10 @@ export function writeWranglerConfig(manifest, options = {}) { }, observability: { enabled: true, - head_sampling_rate: 1 + logs: { + enabled: true, + invocation_logs: false + } }, secrets: { required: ["BETTER_AUTH_SECRET"] @@ -64,8 +73,12 @@ export function writeWranglerConfig(manifest, options = {}) { }; config.vars = { + CLOUDFLARE_OAUTH_MODE: cloudflareOAuth.mode, HQBASE_APP_VERSION: appVersion, HQBASE_WORKER_NAME: manifest.worker.name, + ...(cloudflareOAuth.mode === "customer" + ? { CLOUDFLARE_OAUTH_CLIENT_ID: cloudflareOAuth.clientId } + : {}), ...(manifest.authUrl ? { BETTER_AUTH_URL: manifest.authUrl } : {}) }; const customDomains = [manifest.appDomain].filter(Boolean); @@ -73,5 +86,5 @@ export function writeWranglerConfig(manifest, options = {}) { config.routes = customDomains.map((pattern) => ({ pattern, custom_domain: true })); } - fs.writeFileSync(configPath(manifest.name), `${JSON.stringify(config, null, 2)}\n`); + return config; } diff --git a/scripts/hqbase/destroy.mjs b/scripts/hqbase/destroy.mjs index 7460f7f..7d3cd5a 100644 --- a/scripts/hqbase/destroy.mjs +++ b/scripts/hqbase/destroy.mjs @@ -109,9 +109,9 @@ export function destroy(flags) { } function assertDestroyManifest(manifest, targets) { - if (manifest?.version !== 1) { + if (manifest?.version !== 1 && manifest?.version !== 2) { throw new Error( - `Refusing to destroy: manifest field "version" must be the supported value 1. Migrate or repair the manifest from verified deployment records before retrying.` + `Refusing to destroy: manifest field "version" must be a supported value. Migrate or repair the manifest from verified deployment records before retrying.` ); } diff --git a/scripts/hqbase/install.mjs b/scripts/hqbase/install.mjs index 1f859dc..013d3ca 100644 --- a/scripts/hqbase/install.mjs +++ b/scripts/hqbase/install.mjs @@ -19,6 +19,8 @@ export function install(flags) { const manifest = createManifest(name, { appDomain: optionalString(flags, "app-domain"), authUrl: optionalString(flags, "auth-url"), + oauthClientId: optionalString(flags, "oauth-client-id"), + oauthMode: optionalString(flags, "oauth-mode"), domain, workerName: optionalString(flags, "worker-name"), d1Name: optionalString(flags, "d1-name"), @@ -91,9 +93,14 @@ export function createManifest(name, input) { const queueName = input.queueName ?? `hqbase-${name}-jobs`; validateBucketName(r2Bucket); + const cloudflareOAuth = cloudflareOAuthConfig({ + authUrl: input.authUrl, + clientId: input.oauthClientId, + mode: input.oauthMode + }); return { - version: 1, + version: 2, name, createdAt: new Date().toISOString(), worker: { name: workerName, deployed: false }, @@ -111,6 +118,7 @@ export function createManifest(name, input) { queue: { name: queueName, deadLetterName: `${queueName}-dlq`, created: false }, appDomain: input.appDomain, authUrl: input.authUrl, + cloudflareOAuth, email: input.domain ? { domain: input.domain, @@ -123,6 +131,46 @@ export function createManifest(name, input) { }; } +export function cloudflareOAuthConfig({ authUrl, clientId, mode }) { + const resolvedMode = mode ?? "official"; + if (resolvedMode !== "official" && resolvedMode !== "customer") { + throw new Error('--oauth-mode must be "official" or "customer".'); + } + if (resolvedMode === "official") { + if (clientId) { + throw new Error("--oauth-client-id requires --oauth-mode customer."); + } + return { mode: "official" }; + } + if (!clientId || clientId.length > 256) { + throw new Error("Customer-managed OAuth requires --oauth-client-id."); + } + validateCanonicalHttpsOrigin(authUrl); + return { clientId, mode: "customer" }; +} + +function validateCanonicalHttpsOrigin(value) { + if (!value) { + throw new Error("Customer-managed OAuth requires --auth-url."); + } + let url; + try { + url = new URL(value); + } catch { + throw new Error("--auth-url must be a valid canonical HTTPS origin."); + } + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + throw new Error("--auth-url must be a canonical HTTPS origin without a path."); + } +} + function configureEmail(manifest, options) { const { domain } = manifest.email; run("pnpm", ["exec", "wrangler", "email", "routing", "enable", domain], options); diff --git a/scripts/hqbase/oauth.mjs b/scripts/hqbase/oauth.mjs new file mode 100644 index 0000000..51fa725 --- /dev/null +++ b/scripts/hqbase/oauth.mjs @@ -0,0 +1,42 @@ +import { optionalBoolean, optionalString, requireString } from "./args.mjs"; +import { run } from "./command.mjs"; +import { writeWranglerConfig } from "./config.mjs"; +import { cloudflareOAuthConfig } from "./install.mjs"; +import { configPath, loadManifest, writeManifest } from "./manifest.mjs"; + +export function configureOAuth(flags) { + const name = requireString(flags, "name"); + const mode = requireString(flags, "mode"); + const dryRun = optionalBoolean(flags, "dry-run"); + const skipDeploy = optionalBoolean(flags, "skip-deploy"); + const manifest = updateOAuthManifest(loadManifest(name), { + authUrl: optionalString(flags, "auth-url"), + clientId: optionalString(flags, "client-id"), + mode + }); + + writeManifest(manifest, { dryRun }); + writeWranglerConfig(manifest, { dryRun }); + if (!dryRun && !skipDeploy) { + run("node", ["scripts/release/deploy.mjs", "--config", configPath(name)]); + } + console.log( + dryRun + ? `HQBase deployment "${name}" OAuth configuration is valid.` + : `HQBase deployment "${name}" now uses ${manifest.cloudflareOAuth.mode} OAuth.` + ); +} + +export function updateOAuthManifest(manifest, input) { + const authUrl = input.authUrl ?? manifest.authUrl; + return { + ...manifest, + version: 2, + authUrl, + cloudflareOAuth: cloudflareOAuthConfig({ + authUrl, + clientId: input.clientId, + mode: input.mode + }) + }; +} diff --git a/scripts/release/deploy.mjs b/scripts/release/deploy.mjs index e635937..c517bcc 100644 --- a/scripts/release/deploy.mjs +++ b/scripts/release/deploy.mjs @@ -264,6 +264,15 @@ export function normalizeConfig(config, version, artifactSha256) { ...config.assets, directory: "./dist" }, + observability: { + ...config.observability, + enabled: true, + logs: { + ...config.observability?.logs, + enabled: true, + invocation_logs: false + } + }, vars: { ...config.vars, HQBASE_APP_VERSION: version, diff --git a/test/e2e/staging/app-shell-smoke.spec.ts b/test/e2e/staging/app-shell-smoke.spec.ts index 0d0a591..77362f4 100644 --- a/test/e2e/staging/app-shell-smoke.spec.ts +++ b/test/e2e/staging/app-shell-smoke.spec.ts @@ -1,5 +1,8 @@ import { expect, test } from "@playwright/test"; +const stagingUrl = process.env.HQBASE_STAGING_URL ?? ""; +const oauthClientId = process.env.HQBASE_STAGING_OAUTH_CLIENT_ID ?? ""; + test("deployed HQBase PWA shell is ready", async ({ page, request }) => { await expect .poll( @@ -33,3 +36,23 @@ test("deployed HQBase PWA shell is ready", async ({ page, request }) => { await expect(page.locator("#root > *")).toBeVisible({ timeout: 10_000 }); }).toPass({ intervals: [2_000, 5_000, 10_000], timeout: 60_000 }); }); + +test("customer-managed OAuth starts directly with the exact staging callback", async ({ + request +}) => { + expect(oauthClientId).not.toBe(""); + const response = await request.get("/api/setup/cloudflare/oauth/start", { + maxRedirects: 0 + }); + const target = new URL(response.headers().location ?? ""); + + expect(response.status()).toBe(303); + expect(target.origin).toBe("https://dash.cloudflare.com"); + expect(target.pathname).toBe("/oauth2/auth"); + expect(target.searchParams.get("client_id")).toBe(oauthClientId); + expect(target.searchParams.get("redirect_uri")).toBe( + `${new URL(stagingUrl).origin}/api/setup/cloudflare/oauth/callback` + ); + expect(target.searchParams.get("scope")).toContain("email-routing-rule.write"); + expect(target.searchParams.get("code_challenge_method")).toBe("S256"); +}); diff --git a/test/unit/scripts/deploy.test.mjs b/test/unit/scripts/deploy.test.mjs index 4f0ae63..3b29259 100644 --- a/test/unit/scripts/deploy.test.mjs +++ b/test/unit/scripts/deploy.test.mjs @@ -337,5 +337,23 @@ describe("HQBase release deployment", () => { HQBASE_APP_VERSION: "0.1.23", HQBASE_WORKER_NAME: wranglerConfig.name }); + expect(normalized.observability.logs.invocation_logs).toBe(false); + const customerManaged = normalizeConfig( + { + ...wranglerConfig, + vars: { + BETTER_AUTH_URL: "https://mail.example.com", + CLOUDFLARE_OAUTH_CLIENT_ID: "customer-client", + CLOUDFLARE_OAUTH_MODE: "customer" + } + }, + "0.1.23", + "b".repeat(64) + ); + expect(customerManaged.vars).toMatchObject({ + BETTER_AUTH_URL: "https://mail.example.com", + CLOUDFLARE_OAUTH_CLIENT_ID: "customer-client", + CLOUDFLARE_OAUTH_MODE: "customer" + }); }); }); diff --git a/test/unit/scripts/install.test.mjs b/test/unit/scripts/install.test.mjs index 426d34d..6e97595 100644 --- a/test/unit/scripts/install.test.mjs +++ b/test/unit/scripts/install.test.mjs @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { createManifest } from "../../../scripts/hqbase/install.mjs"; +import { createWranglerConfig } from "../../../scripts/hqbase/config.mjs"; +import { cloudflareOAuthConfig, createManifest } from "../../../scripts/hqbase/install.mjs"; +import { updateOAuthManifest } from "../../../scripts/hqbase/oauth.mjs"; describe("HQBase installation resources", () => { it("creates a fresh, unowned manifest before provisioning", () => { @@ -18,5 +20,81 @@ describe("HQBase installation resources", () => { }); expect(manifest.worker.name).toBe("hqbase-qa"); expect(manifest.queue.name).toBe("hqbase-qa-jobs"); + expect(manifest.version).toBe(2); + expect(manifest.cloudflareOAuth).toEqual({ mode: "official" }); + }); + + it("records customer-managed OAuth as non-secret deployment configuration", () => { + const manifest = createManifest("customer", { + authUrl: "https://mail.example.com", + oauthClientId: "customer-client", + oauthMode: "customer" + }); + + expect(manifest.cloudflareOAuth).toEqual({ + clientId: "customer-client", + mode: "customer" + }); + expect(manifest.authUrl).toBe("https://mail.example.com"); + + const config = createWranglerConfig(manifest); + expect(config.vars).toMatchObject({ + BETTER_AUTH_URL: "https://mail.example.com", + CLOUDFLARE_OAUTH_CLIENT_ID: "customer-client", + CLOUDFLARE_OAUTH_MODE: "customer" + }); + expect(config.observability.logs.invocation_logs).toBe(false); + }); + + it("fails closed on incomplete customer-managed OAuth configuration", () => { + expect(() => + cloudflareOAuthConfig({ + authUrl: undefined, + clientId: "customer-client", + mode: "customer" + }) + ).toThrow("requires --auth-url"); + expect(() => + cloudflareOAuthConfig({ + authUrl: "https://mail.example.com/path", + clientId: "customer-client", + mode: "customer" + }) + ).toThrow("without a path"); + expect(() => + cloudflareOAuthConfig({ + authUrl: "https://mail.example.com", + clientId: "customer-client", + mode: "token" + }) + ).toThrow('must be "official" or "customer"'); + expect(() => + cloudflareOAuthConfig({ + authUrl: "https://mail.example.com", + clientId: "unexpected", + mode: "official" + }) + ).toThrow("requires --oauth-mode customer"); + }); + + it("switches an existing manifest between customer-managed and official OAuth", () => { + const installed = createManifest("existing", {}); + const customer = updateOAuthManifest(installed, { + authUrl: "https://mail.example.com", + clientId: "customer-client", + mode: "customer" + }); + const official = updateOAuthManifest(customer, { + authUrl: undefined, + clientId: undefined, + mode: "official" + }); + + expect(customer.cloudflareOAuth).toEqual({ + clientId: "customer-client", + mode: "customer" + }); + expect(official.cloudflareOAuth).toEqual({ mode: "official" }); + expect(official.authUrl).toBe("https://mail.example.com"); }); }); diff --git a/test/unit/worker/features/cloudflare/oauth.test.ts b/test/unit/worker/features/cloudflare/oauth.test.ts index 1b45cfb..79d7059 100644 --- a/test/unit/worker/features/cloudflare/oauth.test.ts +++ b/test/unit/worker/features/cloudflare/oauth.test.ts @@ -9,9 +9,14 @@ import { describe, expect, it, vi } from "vitest"; const env = { BETTER_AUTH_SECRET: "test-better-auth-secret-with-enough-entropy", - CLOUDFLARE_OAUTH_CLIENT_ID: "hqbase-client", - CLOUDFLARE_OAUTH_REDIRECT_URI: "https://auth.hqbase.io/oauth/callback", - CLOUDFLARE_OAUTH_RELAY_URL: "https://auth.hqbase.io" + CLOUDFLARE_OAUTH_MODE: "official" +}; + +const customerEnv = { + BETTER_AUTH_SECRET: "test-better-auth-secret-with-enough-entropy", + BETTER_AUTH_URL: "https://mail.example.com", + CLOUDFLARE_OAUTH_CLIENT_ID: "customer-client", + CLOUDFLARE_OAUTH_MODE: "customer" }; const updateFlow = { @@ -143,6 +148,87 @@ describe("HQBase runtime Cloudflare OAuth", () => { expect(finished.headers.get("set-cookie")).not.toContain("setup-oauth-secret"); }); + it("starts customer-managed OAuth directly with an exact callback and fixed scopes", async () => { + const response = await startRuntimeCloudflareOAuth( + new Request("https://mail.example.com/api/updates/cloudflare/oauth/start"), + customerEnv, + updateFlow + ); + const target = new URL(response.headers.get("location") ?? ""); + + expect(target.origin).toBe("https://dash.cloudflare.com"); + expect(target.pathname).toBe("/oauth2/auth"); + expect(target.searchParams.get("client_id")).toBe("customer-client"); + expect(target.searchParams.get("redirect_uri")).toBe( + "https://mail.example.com/api/updates/cloudflare/oauth/callback" + ); + expect(target.searchParams.get("scope")).toBe( + "workers-scripts.write workers-ci.write zone.read" + ); + expect(target.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + it("exchanges and revokes customer-managed grants with the customer client", async () => { + const started = await startRuntimeCloudflareOAuth( + new Request("https://mail.example.com/api/updates/cloudflare/oauth/start"), + customerEnv, + updateFlow + ); + const startCookies = started.headers.get("set-cookie") ?? ""; + const state = cookieValue(startCookies, "hqb_cf_oauth_state"); + const verifier = cookieValue(startCookies, "hqb_cf_oauth_verifier"); + const tokenFetch = vi.fn(() => + Promise.resolve(Response.json({ access_token: "customer-oauth-secret" })) + ); + + await finishRuntimeCloudflareOAuth( + new Request( + `https://mail.example.com/api/updates/cloudflare/oauth/callback?code=code-1&state=${state}`, + { + headers: { + cookie: cookieHeader({ + hqb_cf_oauth_state: state, + hqb_cf_oauth_verifier: verifier + }) + } + } + ), + customerEnv, + updateFlow, + tokenFetch + ); + expect(String(tokenFetch.mock.calls[0]?.[1]?.body)).toContain("client_id=customer-client"); + expect(String(tokenFetch.mock.calls[0]?.[1]?.body)).toContain( + "redirect_uri=https%3A%2F%2Fmail.example.com%2Fapi%2Fupdates%2Fcloudflare%2Foauth%2Fcallback" + ); + + const revokeFetch = vi.fn(() => Promise.resolve(new Response(null))); + await revokeRuntimeCloudflareGrant("customer-oauth-secret", customerEnv, revokeFetch); + expect(String(revokeFetch.mock.calls[0]?.[1]?.body)).toBe( + "client_id=customer-client&token=customer-oauth-secret" + ); + }); + + it("fails closed on incomplete or non-canonical customer-managed configuration", async () => { + const { BETTER_AUTH_URL: _removed, ...missingOriginEnv } = customerEnv; + for (const [requestUrl, invalidEnv] of [ + ["https://mail.example.com/api/updates/cloudflare/oauth/start", missingOriginEnv], + ["https://alternate.example.com/api/updates/cloudflare/oauth/start", customerEnv], + [ + "https://mail.example.com/api/updates/cloudflare/oauth/start", + { ...customerEnv, CLOUDFLARE_OAUTH_MODE: "token" } + ] + ] as const) { + const response = await startRuntimeCloudflareOAuth( + new Request(requestUrl), + invalidEnv, + updateFlow + ); + expect(response.status).toBe(303); + expect(response.headers.get("location")).toContain("cloudflare=config"); + } + }); + it("requires a runtime OAuth grant and revokes it with the HQBase client", async () => { await expect( resolveRuntimeCloudflareGrant(new Request("https://mail.example.com/api/updates/apply"), env) @@ -151,7 +237,7 @@ describe("HQBase runtime Cloudflare OAuth", () => { const revokeFetch = vi.fn(() => Promise.resolve(new Response(null))); await revokeRuntimeCloudflareGrant("runtime-oauth-secret", env, revokeFetch); expect(String(revokeFetch.mock.calls[0]?.[1]?.body)).toBe( - "client_id=hqbase-client&token=runtime-oauth-secret" + "client_id=1c413f324b518b452096929b847e6703&token=runtime-oauth-secret" ); }); }); diff --git a/test/unit/worker/features/setup/cloudflare-setup-api.test.ts b/test/unit/worker/features/setup/cloudflare-setup-api.test.ts index cda3990..404bf9a 100644 --- a/test/unit/worker/features/setup/cloudflare-setup-api.test.ts +++ b/test/unit/worker/features/setup/cloudflare-setup-api.test.ts @@ -100,7 +100,7 @@ describe("Cloudflare setup API", () => { vi.stubGlobal("fetch", fetchMock); await expect(verifyCloudflareToken({ apiToken: "token-123" })).rejects.toThrow( - "Cloudflare rejected the temporary authorization. Authorize Cloudflare again." + "Cloudflare rejected the temporary authorization. Authorize again, or configure customer-managed OAuth from the deployment guide." ); }); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 6f632bf..90e5c41 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,6 +1,6 @@ /* eslint-disable */ // Generated by Wrangler by running `wrangler types` (hash: 24b30229e48a27be5a956f19fccf9d08) -// Runtime types generated with workerd@1.20260708.1 2026-07-11 nodejs_compat +// Runtime types generated with workerd@1.20260722.1 2026-07-28 nodejs_compat interface __BaseEnv_Env { MAIL_OBJECTS: R2Bucket; DB: D1Database; diff --git a/worker/features/cloudflare/oauth-config.ts b/worker/features/cloudflare/oauth-config.ts new file mode 100644 index 0000000..45e31b2 --- /dev/null +++ b/worker/features/cloudflare/oauth-config.ts @@ -0,0 +1,126 @@ +import type { WorkerEnv } from "../../lib/env"; +import { AppError } from "../../lib/errors"; +import { hqbaseProductConfig } from "../../lib/product-config"; + +const AUTHORIZATION_ENDPOINT = "https://dash.cloudflare.com/oauth2/auth"; + +export type OAuthConfigEnv = Pick< + WorkerEnv, + "BETTER_AUTH_URL" | "CLOUDFLARE_OAUTH_CLIENT_ID" | "CLOUDFLARE_OAUTH_MODE" +>; + +type CloudflareOAuthMode = "customer" | "official"; + +type OAuthFlowConfig = { + callbackPath: string; + operation: "domains" | "setup" | "updates"; +}; + +export function resolveOAuthConfig( + request: Request, + env: OAuthConfigEnv, + flow: OAuthFlowConfig +): { + clientId: string; + mode: CloudflareOAuthMode; + redirectUri: string; +} { + const mode = oauthMode(env); + if (mode === "official") { + return { + clientId: hqbaseProductConfig.cloudflareOAuthClientId, + mode, + redirectUri: hqbaseProductConfig.cloudflareOAuthRedirectUri + }; + } + const canonicalOrigin = customerOAuthOrigin(env.BETTER_AUTH_URL); + if (new URL(request.url).origin !== canonicalOrigin) { + throw invalidOAuthConfig( + "Open the canonical HQBase URL before authorizing the customer-managed OAuth client." + ); + } + return { + clientId: oauthClientId(env), + mode, + redirectUri: `${canonicalOrigin}${flow.callbackPath}` + }; +} + +export function cloudflareAuthorizationUrl( + request: Request, + config: ReturnType, + flow: OAuthFlowConfig, + state: string, + codeChallenge: string +): URL { + if (config.mode === "official") { + const relay = new URL("/oauth/authorize", hqbaseProductConfig.cloudflareOAuthRelayUrl); + relay.searchParams.set("callback", `${new URL(request.url).origin}${flow.callbackPath}`); + relay.searchParams.set("operation", flow.operation); + relay.searchParams.set("state", state); + relay.searchParams.set("code_challenge", codeChallenge); + return relay; + } + + const target = new URL(AUTHORIZATION_ENDPOINT); + target.searchParams.set("client_id", config.clientId); + target.searchParams.set("redirect_uri", config.redirectUri); + target.searchParams.set("response_type", "code"); + target.searchParams.set("state", state); + target.searchParams.set("code_challenge", codeChallenge); + target.searchParams.set("code_challenge_method", "S256"); + target.searchParams.set("scope", hqbaseProductConfig.cloudflareOAuthScopes[flow.operation]); + return target; +} + +export function oauthClientId( + env: Pick +): string { + if (oauthMode(env) === "official") return hqbaseProductConfig.cloudflareOAuthClientId; + const clientId = env.CLOUDFLARE_OAUTH_CLIENT_ID?.trim(); + if (!clientId || clientId.length > 256) { + throw invalidOAuthConfig("Customer-managed OAuth requires a valid CLOUDFLARE_OAUTH_CLIENT_ID."); + } + return clientId; +} + +export function isInvalidOAuthConfig(error: unknown): error is AppError { + return error instanceof AppError && error.code === "CLOUDFLARE_OAUTH_CONFIG_INVALID"; +} + +function oauthMode(env: Pick): CloudflareOAuthMode { + const value = env.CLOUDFLARE_OAUTH_MODE?.trim() || "official"; + if (value !== "official" && value !== "customer") { + throw invalidOAuthConfig('CLOUDFLARE_OAUTH_MODE must be "official" or "customer".'); + } + return value; +} + +function customerOAuthOrigin(value: string | undefined): string { + if (!value) { + throw invalidOAuthConfig( + "Customer-managed OAuth requires BETTER_AUTH_URL to be the canonical HTTPS HQBase origin." + ); + } + let url: URL; + try { + url = new URL(value); + } catch { + throw invalidOAuthConfig("BETTER_AUTH_URL must be a valid canonical HTTPS origin."); + } + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + throw invalidOAuthConfig("BETTER_AUTH_URL must be a canonical HTTPS origin without a path."); + } + return url.origin; +} + +function invalidOAuthConfig(message: string): AppError { + return new AppError("CLOUDFLARE_OAUTH_CONFIG_INVALID", message, 500); +} diff --git a/worker/features/cloudflare/oauth.ts b/worker/features/cloudflare/oauth.ts index 85c7593..12fe88c 100644 --- a/worker/features/cloudflare/oauth.ts +++ b/worker/features/cloudflare/oauth.ts @@ -2,7 +2,13 @@ import { z } from "zod"; import type { WorkerEnv } from "../../lib/env"; import { AppError } from "../../lib/errors"; -import { hqbaseProductConfig } from "../../lib/product-config"; +import { + cloudflareAuthorizationUrl, + isInvalidOAuthConfig, + type OAuthConfigEnv, + oauthClientId, + resolveOAuthConfig +} from "./oauth-config"; const VERIFIER_COOKIE = "hqb_cf_oauth_verifier"; const STATE_COOKIE = "hqb_cf_oauth_state"; @@ -12,10 +18,6 @@ const GRANT_COOKIE_TTL_SECONDS = 15 * 60; const TOKEN_ENDPOINT = "https://dash.cloudflare.com/oauth2/token"; const REVOKE_ENDPOINT = "https://dash.cloudflare.com/oauth2/revoke"; -type OAuthConfigEnv = Pick< - WorkerEnv, - "CLOUDFLARE_OAUTH_CLIENT_ID" | "CLOUDFLARE_OAUTH_REDIRECT_URI" | "CLOUDFLARE_OAUTH_RELAY_URL" ->; type OAuthCookieEnv = Pick; export type RuntimeCloudflareOAuthFlow = @@ -37,16 +39,19 @@ export async function startRuntimeCloudflareOAuth( env: OAuthConfigEnv, flow: RuntimeCloudflareOAuthFlow ): Promise { - const config = oauthConfig(env); + let config: ReturnType; + try { + config = resolveOAuthConfig(request, env, flow); + } catch (error) { + if (isInvalidOAuthConfig(error)) return oauthResultRedirect(request, "config", flow); + throw error; + } const verifier = randomBase64Url(64); const state = randomBase64Url(32); - const relay = new URL("/oauth/authorize", config.relayUrl); - relay.searchParams.set("callback", `${new URL(request.url).origin}${flow.callbackPath}`); - relay.searchParams.set("operation", flow.operation); - relay.searchParams.set("state", state); - relay.searchParams.set("code_challenge", await sha256Base64Url(verifier)); + const codeChallenge = await sha256Base64Url(verifier); + const target = cloudflareAuthorizationUrl(request, config, flow, state, codeChallenge); - const headers = new Headers({ "cache-control": "no-store", location: relay.toString() }); + const headers = new Headers({ "cache-control": "no-store", location: target.toString() }); headers.append("set-cookie", secureCookie(VERIFIER_COOKIE, verifier, OAUTH_COOKIE_TTL_SECONDS)); headers.append("set-cookie", secureCookie(STATE_COOKIE, state, OAUTH_COOKIE_TTL_SECONDS)); headers.append("set-cookie", clearRuntimeCloudflareGrantCookie()); @@ -73,7 +78,13 @@ export async function finishRuntimeCloudflareOAuth( const code = url.searchParams.get("code") ?? ""; if (!code) return oauthResultRedirect(request, "invalid", flow); - const config = oauthConfig(env); + let config: ReturnType; + try { + config = resolveOAuthConfig(request, env, flow); + } catch (error) { + if (isInvalidOAuthConfig(error)) return oauthResultRedirect(request, "config", flow); + throw error; + } const tokenResponse = await fetcher(TOKEN_ENDPOINT, { body: new URLSearchParams({ client_id: config.clientId, @@ -114,7 +125,7 @@ export async function resolveRuntimeCloudflareGrant( if (!grant) { throw new AppError( "CLOUDFLARE_ACCESS_REQUIRED", - "Authorize Cloudflare again. If your organization blocks HQBase, ask a Cloudflare administrator to allow the OAuth application.", + "Authorize Cloudflare again. If your organization blocks the public HQBase OAuth application, configure customer-managed OAuth and retry.", 401 ); } @@ -123,11 +134,10 @@ export async function resolveRuntimeCloudflareGrant( export async function revokeRuntimeCloudflareGrant( accessToken: string, - env: Pick, + env: Pick, fetcher: typeof fetch = fetch ): Promise { - const clientId = - env.CLOUDFLARE_OAUTH_CLIENT_ID?.trim() || hqbaseProductConfig.cloudflareOAuthClientId; + const clientId = oauthClientId(env); const response = await fetcher(REVOKE_ENDPOINT, { body: new URLSearchParams({ client_id: clientId, token: accessToken }), headers: { "content-type": "application/x-www-form-urlencoded" }, @@ -166,19 +176,6 @@ async function readGrant(request: Request, env: OAuthCookieEnv): Promise