diff --git a/.changeset/access-service-token-support.md b/.changeset/access-service-token-support.md new file mode 100644 index 0000000000..bca4c0f64d --- /dev/null +++ b/.changeset/access-service-token-support.md @@ -0,0 +1,17 @@ +--- +"wrangler": minor +--- + +Add support for Cloudflare Access Service Token authentication via environment variables + +When running `wrangler dev` with remote bindings behind a Cloudflare Access-protected domain, Wrangler previously required `cloudflared access login` which opens a browser for interactive authentication. This does not work in CI/CD environments. + +You can now set the `CLOUDFLARE_ACCESS_CLIENT_ID` and `CLOUDFLARE_ACCESS_CLIENT_SECRET` environment variables to authenticate using an Access Service Token instead: + +```sh +export CLOUDFLARE_ACCESS_CLIENT_ID=".access" +export CLOUDFLARE_ACCESS_CLIENT_SECRET="" +wrangler dev +``` + +Additionally, when running in a non-interactive environment (CI) without these credentials, Wrangler now throws a clear, actionable error instead of hanging on `cloudflared access login`. diff --git a/packages/workers-utils/src/environment-variables/factory.ts b/packages/workers-utils/src/environment-variables/factory.ts index 782e0f1639..4e6ae43b7d 100644 --- a/packages/workers-utils/src/environment-variables/factory.ts +++ b/packages/workers-utils/src/environment-variables/factory.ts @@ -97,6 +97,13 @@ type VariableNames = /** Direct authorization token for API requests. */ | "WRANGLER_CF_AUTHORIZATION_TOKEN" + // ## Cloudflare Access Service Token (for CI/non-interactive environments) + + /** Cloudflare Access Service Token Client ID. Used to authenticate with Access-protected domains in non-interactive environments (e.g. CI). */ + | "CLOUDFLARE_ACCESS_CLIENT_ID" + /** Cloudflare Access Service Token Client Secret. Used with CLOUDFLARE_ACCESS_CLIENT_ID. */ + | "CLOUDFLARE_ACCESS_CLIENT_SECRET" + // ## Experimental Feature Flags /** Enable the local explorer UI at /cdn-cgi/explorer (experimental, default: false). */ diff --git a/packages/wrangler/src/__tests__/access.test.ts b/packages/wrangler/src/__tests__/access.test.ts index 570116e93d..da611448ae 100644 --- a/packages/wrangler/src/__tests__/access.test.ts +++ b/packages/wrangler/src/__tests__/access.test.ts @@ -1,31 +1,142 @@ -import { UserError } from "@cloudflare/workers-utils"; -import { beforeEach, describe, it } from "vitest"; -import { domainUsesAccess, getAccessToken } from "../user/access"; +import ci from "ci-info"; +import { beforeEach, describe, it, vi } from "vitest"; +import { + clearAccessCaches, + domainUsesAccess, + getAccessHeaders, +} from "../user/access"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { useMockIsTTY } from "./helpers/mock-istty"; import { msw, mswAccessHandlers } from "./helpers/msw"; describe("access", () => { + const { setIsTTY } = useMockIsTTY(); + const std = mockConsoleMethods(); + beforeEach(() => { + clearAccessCaches(); msw.use(...mswAccessHandlers); }); - describe("basic", () => { + describe("domainUsesAccess", () => { it("should correctly detect an access protected domain", async ({ expect, }) => { expect(await domainUsesAccess("access-protected.com")).toBeTruthy(); expect(await domainUsesAccess("not-access-protected.com")).toBeFalsy(); }); - it("should not fail without cloudflared installed", async ({ expect }) => { - expect(await getAccessToken("not-access-protected.com")).toBeFalsy(); - }); - it("should error without cloudflared installed on an access protected domain", async ({ + }); + + describe("getAccessHeaders", () => { + it("should return empty headers for non-access-protected domains", async ({ expect, }) => { - await expect(getAccessToken("access-protected.com")).rejects.toEqual( - new UserError( - "To use Wrangler with Cloudflare Access, please install `cloudflared` from https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation" - ) - ); + expect(await getAccessHeaders("not-access-protected.com")).toEqual({}); + }); + + describe("service token authentication", () => { + it("should return service token headers when both env vars are set", async ({ + expect, + }) => { + vi.stubEnv("CLOUDFLARE_ACCESS_CLIENT_ID", "test-client-id.access"); + vi.stubEnv("CLOUDFLARE_ACCESS_CLIENT_SECRET", "test-client-secret"); + + const headers = await getAccessHeaders("access-protected.com"); + expect(headers).toEqual({ + "CF-Access-Client-Id": "test-client-id.access", + "CF-Access-Client-Secret": "test-client-secret", + }); + // No warning is presented since both env variables are set + expect(std.warn).toMatchInlineSnapshot(`""`); + }); + + it("should warn when only CLOUDFLARE_ACCESS_CLIENT_ID is set", async ({ + expect, + }) => { + vi.stubEnv("CLOUDFLARE_ACCESS_CLIENT_ID", "test-client-id.access"); + setIsTTY(false); + + await expect( + getAccessHeaders("access-protected.com") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The domain "access-protected.com" is behind Cloudflare Access, but no Access Service Token credentials were found and the current environment is non-interactive. +Set the CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET environment variables to authenticate with an Access Service Token. +See https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/]` + ); + expect(std.warn).toContain( + "Both CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET must be set" + ); + expect(std.warn).toContain( + "Only CLOUDFLARE_ACCESS_CLIENT_ID was found" + ); + }); + + it("should warn when only CLOUDFLARE_ACCESS_CLIENT_SECRET is set", async ({ + expect, + }) => { + vi.stubEnv("CLOUDFLARE_ACCESS_CLIENT_SECRET", "test-client-secret"); + setIsTTY(false); + + await expect( + getAccessHeaders("access-protected.com") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The domain "access-protected.com" is behind Cloudflare Access, but no Access Service Token credentials were found and the current environment is non-interactive. +Set the CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET environment variables to authenticate with an Access Service Token. +See https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/]` + ); + expect(std.warn).toContain( + "Both CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET must be set" + ); + expect(std.warn).toContain( + "Only CLOUDFLARE_ACCESS_CLIENT_SECRET was found" + ); + }); + }); + + describe("non-interactive environment", () => { + it("should throw actionable error when non-interactive and no service token", async ({ + expect, + }) => { + setIsTTY(false); + + await expect( + getAccessHeaders("access-protected.com") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The domain "access-protected.com" is behind Cloudflare Access, but no Access Service Token credentials were found and the current environment is non-interactive. +Set the CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET environment variables to authenticate with an Access Service Token. +See https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/]` + ); + }); + + it("should throw actionable error when in CI and no service token", async ({ + expect, + }) => { + setIsTTY(true); + vi.mocked(ci).isCI = true; + + await expect( + getAccessHeaders("access-protected.com") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The domain "access-protected.com" is behind Cloudflare Access, but no Access Service Token credentials were found and the current environment is non-interactive. +Set the CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET environment variables to authenticate with an Access Service Token. +See https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/]` + ); + }); + }); + + describe("interactive environment (cloudflared fallback)", () => { + it("should error without cloudflared installed on an access protected domain", async ({ + expect, + }) => { + setIsTTY(true); + vi.mocked(ci).isCI = false; + + await expect( + getAccessHeaders("access-protected.com") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: To use Wrangler with Cloudflare Access, please install \`cloudflared\` from https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation]` + ); + }); }); }); }); diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts index 9a240cdc08..11f5476a4b 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts @@ -11,7 +11,7 @@ import { createRemoteWorkerInit, getWorkerAccountAndContext, } from "../../../dev/remote"; -import { getAccessToken } from "../../../user/access"; +import { getAccessHeaders } from "../../../user/access"; import { FakeBus } from "../../helpers/fake-bus"; import { mockConsoleMethods } from "../../helpers/mock-console"; import { useTeardown } from "../../helpers/teardown"; @@ -35,7 +35,7 @@ vi.mock("../../../dev/remote", () => ({ })); vi.mock("../../../user/access", () => ({ - getAccessToken: vi.fn(), + getAccessHeaders: vi.fn(), domainUsesAccess: vi.fn(), })); @@ -163,7 +163,7 @@ describe("RemoteRuntimeController", () => { tailUrl: "wss://test.workers.dev/tail", }); - vi.mocked(getAccessToken).mockResolvedValue(undefined); + vi.mocked(getAccessHeaders).mockResolvedValue({}); }); describe("preview token refresh", () => { diff --git a/packages/wrangler/src/__tests__/helpers/msw/handlers/access.ts b/packages/wrangler/src/__tests__/helpers/msw/handlers/access.ts index b0b1b84a71..83fbf05270 100644 --- a/packages/wrangler/src/__tests__/helpers/msw/handlers/access.ts +++ b/packages/wrangler/src/__tests__/helpers/msw/handlers/access.ts @@ -1,21 +1,13 @@ import { http, HttpResponse } from "msw"; export default [ - http.get( - "*access-protected.com*", - () => { - return HttpResponse.json(null, { - status: 302, - headers: { location: "access-protected-com.cloudflareaccess.com" }, - }); - }, - { once: true } - ), - http.get( - "*not-access-protected.com*", - () => { - return HttpResponse.json("OK", { status: 200 }); - }, - { once: true } - ), + http.get("https://access-protected.com/", () => { + return HttpResponse.json(null, { + status: 302, + headers: { location: "access-protected-com.cloudflareaccess.com" }, + }); + }), + http.get("https://not-access-protected.com/", () => { + return HttpResponse.json("OK", { status: 200 }); + }), ]; diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index 585106aa60..039bc5bdca 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -16,7 +16,7 @@ import { import { logger } from "../../logger"; import { TRACE_VERSION } from "../../tail/createTail"; import { realishPrintLogs } from "../../tail/printing"; -import { getAccessToken } from "../../user/access"; +import { getAccessHeaders } from "../../user/access"; import { retryOnAPIFailure } from "../../utils/retry"; import { RuntimeController } from "./BaseController"; import { castErrorCause } from "./events"; @@ -310,7 +310,7 @@ export class RemoteRuntimeController extends RuntimeController { return; } - const accessToken = await getAccessToken(token.host); + const accessHeaders = await getAccessHeaders(token.host); this.emitReloadCompleteEvent({ type: "reloadComplete", @@ -324,7 +324,7 @@ export class RemoteRuntimeController extends RuntimeController { }, headers: { "cf-workers-preview-token": token.value, - ...(accessToken ? { Cookie: `CF_Authorization=${accessToken}` } : {}), + ...accessHeaders, "cf-connecting-ip": "", }, liveReload: config.dev.liveReload, diff --git a/packages/wrangler/src/dev/create-worker-preview.ts b/packages/wrangler/src/dev/create-worker-preview.ts index d566cc0072..3562c652e3 100644 --- a/packages/wrangler/src/dev/create-worker-preview.ts +++ b/packages/wrangler/src/dev/create-worker-preview.ts @@ -6,7 +6,7 @@ import { fetchResult } from "../cfetch"; import { createWorkerUploadForm } from "../deployment-bundle/create-worker-upload-form"; import { logger } from "../logger"; import { getWorkersDevSubdomain } from "../routes"; -import { getAccessToken } from "../user/access"; +import { getAccessHeaders } from "../user/access"; import type { ApiCredentials } from "../user"; import type { CfWorkerInitWithName } from "./remote"; import type { @@ -138,12 +138,8 @@ async function tryExpandToken( try { const switchedExchangeUrl = switchHost(exchangeUrl, ctx.host, !!ctx.zone); - const headers: HeadersInit = {}; - const accessToken = await getAccessToken(switchedExchangeUrl.hostname); - - if (accessToken) { - headers.cookie = `CF_Authorization=${accessToken}`; - } + const accessHeaders = await getAccessHeaders(switchedExchangeUrl.hostname); + const headers: HeadersInit = { ...accessHeaders }; logger.debugWithSanitization( "-- START EXCHANGE API REQUEST:", diff --git a/packages/wrangler/src/user/access.ts b/packages/wrangler/src/user/access.ts index a9f3f06004..e05bd6a4ef 100644 --- a/packages/wrangler/src/user/access.ts +++ b/packages/wrangler/src/user/access.ts @@ -1,12 +1,27 @@ import { spawnSync } from "node:child_process"; import { UserError } from "@cloudflare/workers-utils"; import { fetch } from "undici"; +import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; +import { + getAccessClientIdFromEnv, + getAccessClientSecretFromEnv, +} from "./auth-variables"; -const cache: Record = {}; +const headersCache: Record> = {}; const usesAccessCache = new Map(); +/** + * Clear internal caches. Exported for use in tests only. + */ +export function clearAccessCaches(): void { + for (const key of Object.keys(headersCache)) { + delete headersCache[key]; + } + usesAccessCache.clear(); +} + export async function domainUsesAccess(domain: string): Promise { logger.debug("Checking if domain has Access enabled:", domain); @@ -43,21 +58,73 @@ export async function domainUsesAccess(domain: string): Promise { return false; } } -export async function getAccessToken( + +/** + * Get the headers needed to authenticate with an Access-protected domain. + * + * @param domain The hostname of the Access-protected domain (e.g. `"example.com"`). + * @returns + * - Service token headers (`CF-Access-Client-Id` + `CF-Access-Client-Secret`) if env vars are set + * - A `Cookie: CF_Authorization=...` header if obtained via `cloudflared` (interactive only) + * - An empty object if the domain is not behind Access + * @throws {UserError} If the response does not contain a `CF_Authorization` cookie, + * indicating the service token is invalid, expired, or lacks a Service Auth policy. + * Also throws in non-interactive environments when the domain is behind Access + * but no service token credentials are configured. + */ +export async function getAccessHeaders( domain: string -): Promise { +): Promise> { if (!(await domainUsesAccess(domain))) { - return undefined; + return {}; } - logger.debug("Fetching Access token for domain:", domain); - if (cache[domain]) { - logger.debug("Using cached Access token for domain:", cache[domain]); - return cache[domain]; + logger.debug("Getting Access headers for domain:", domain); + if (headersCache[domain]) { + logger.debug("Using cached Access headers for domain:", domain); + return headersCache[domain]; } + + // 1. If Access Service Token credentials are provided, use them directly + const clientId = getAccessClientIdFromEnv(); + const clientSecret = getAccessClientSecretFromEnv(); + + if (clientId && clientSecret) { + logger.debug("Using Access Service Token headers for domain:", domain); + const headers = { + "CF-Access-Client-Id": clientId, + "CF-Access-Client-Secret": clientSecret, + }; + headersCache[domain] = headers; + return headers; + } + + // Warn if only one of the two env vars is set + if (clientId !== undefined || clientSecret !== undefined) { + logger.warn( + "Both CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET must be set to use Access Service Token authentication. " + + `Only ${ + clientId !== undefined + ? "CLOUDFLARE_ACCESS_CLIENT_ID" + : "CLOUDFLARE_ACCESS_CLIENT_SECRET" + } was found.` + ); + } + + // 2. If non-interactive (CI), error with actionable message + if (isNonInteractiveOrCI()) { + throw new UserError( + `The domain "${domain}" is behind Cloudflare Access, but no Access Service Token credentials were found ` + + `and the current environment is non-interactive.\n` + + `Set the CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET environment variables ` + + `to authenticate with an Access Service Token.\n` + + `See https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/` + ); + } + + // 3. Interactive: fall back to cloudflared logger.debug("Spawning cloudflared to get Access token for domain:"); const output = spawnSync("cloudflared", ["access", "login", domain]); if (output.error) { - // The cloudflared binary is not installed throw new UserError( "To use Wrangler with Cloudflare Access, please install `cloudflared` from https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation" ); @@ -66,9 +133,10 @@ export async function getAccessToken( logger.debug("cloudflared output:", stringOutput); const matches = stringOutput.match(/fetched your token:\n\n(.*)/m); if (matches && matches.length >= 2) { - cache[domain] = matches[1]; - logger.debug("Caching Access token for domain:", matches[1]); - return matches[1]; + const headers = { Cookie: `CF_Authorization=${matches[1]}` }; + headersCache[domain] = headers; + logger.debug("Caching Access headers for domain:", domain); + return headers; } throw new Error("Failed to authenticate with Cloudflare Access"); } diff --git a/packages/wrangler/src/user/auth-variables.ts b/packages/wrangler/src/user/auth-variables.ts index 218d452feb..6af5a966af 100644 --- a/packages/wrangler/src/user/auth-variables.ts +++ b/packages/wrangler/src/user/auth-variables.ts @@ -3,7 +3,7 @@ import { getEnvironmentVariableFactory, } from "@cloudflare/workers-utils"; import { logger } from "../logger"; -import { getAccessToken } from "./access"; +import { getAccessHeaders } from "./access"; /** * `CLOUDFLARE_ACCOUNT_ID` overrides the account inferred from the current user. @@ -103,10 +103,35 @@ export const getWranglerR2SqlAuthToken = getEnvironmentVariableFactory({ }); /** - * Set the `WRANGLER_CF_AUTHORIZATION_TOKEN` to the CF_Authorization token found at https://dash.staging.cloudflare.com/bypass-limits - * if you want to access the staging environment, triggered by `WRANGLER_API_ENVIRONMENT=staging`. + * `CLOUDFLARE_ACCESS_CLIENT_ID` is the Client ID of a Cloudflare Access Service Token. + * Used together with `CLOUDFLARE_ACCESS_CLIENT_SECRET` to authenticate with + * Access-protected domains in non-interactive environments (e.g. CI). + * + * @see https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/ + */ +export const getAccessClientIdFromEnv = getEnvironmentVariableFactory({ + variableName: "CLOUDFLARE_ACCESS_CLIENT_ID", +}); + +/** + * `CLOUDFLARE_ACCESS_CLIENT_SECRET` is the Client Secret of a Cloudflare Access Service Token. + * Used together with `CLOUDFLARE_ACCESS_CLIENT_ID` to authenticate with + * Access-protected domains in non-interactive environments (e.g. CI). + * + * @see https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/ + */ +export const getAccessClientSecretFromEnv = getEnvironmentVariableFactory({ + variableName: "CLOUDFLARE_ACCESS_CLIENT_SECRET", +}); + +/** + * Get headers needed to authenticate with the Cloudflare auth domain (e.g. staging). + * + * Checks `WRANGLER_CF_AUTHORIZATION_TOKEN` first, then falls back to `getAccessHeaders`. */ -export const getCloudflareAccessToken = async () => { +export const getCloudflareAccessHeaders = async (): Promise< + Record +> => { const env = getEnvironmentVariableFactory({ variableName: "WRANGLER_CF_AUTHORIZATION_TOKEN", })(); @@ -114,8 +139,8 @@ export const getCloudflareAccessToken = async () => { // If the environment variable is defined, go ahead and use it. if (env !== undefined) { logger.debug("Using WRANGLER_CF_AUTHORIZATION_TOKEN from environment", env); - return env; + return { Cookie: `CF_Authorization=${env}` }; } - return getAccessToken(getAuthDomainFromEnv()); + return getAccessHeaders(getAuthDomainFromEnv()); }; diff --git a/packages/wrangler/src/user/user.ts b/packages/wrangler/src/user/user.ts index a3887861d9..3337da9508 100644 --- a/packages/wrangler/src/user/user.ts +++ b/packages/wrangler/src/user/user.ts @@ -239,7 +239,7 @@ import { getAuthDomainFromEnv, getAuthUrlFromEnv, getClientIdFromEnv, - getCloudflareAccessToken, + getCloudflareAccessHeaders, getCloudflareAccountIdFromEnv, getCloudflareAPITokenFromEnv, getCloudflareGlobalAuthEmailFromEnv, @@ -1401,8 +1401,9 @@ async function fetchAuthToken(body: URLSearchParams) { logger.debug( "Using Cloudflare Access to get an access token for the auth request" ); - // We are trying to access the staging API so we need an "access token". - headers["Cookie"] = `CF_Authorization=${await getCloudflareAccessToken()}`; + // We are trying to access a domain behind Access so we need auth headers. + const accessHeaders = await getCloudflareAccessHeaders(); + Object.assign(headers, accessHeaders); } logger.debug("Fetching auth token from", getTokenUrlFromEnv()); try {