From 7539a9bfcf03a14b2c16f281d541b6bc45523a80 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Tue, 2 Jun 2026 21:38:22 +0100 Subject: [PATCH 1/4] [workers-auth] Extract OAuth 2.0 + PKCE flow into a new package (#14121) --- .changeset/extract-workers-auth-oauth-flow.md | 20 + packages/workers-auth/AGENTS.md | 56 + packages/workers-auth/LICENSE-APACHE | 176 +++ packages/workers-auth/LICENSE-MIT | 25 + packages/workers-auth/README.md | 31 + packages/workers-auth/package.json | 72 + packages/workers-auth/scripts/deps.ts | 15 + packages/workers-auth/src/access.ts | 201 +++ packages/workers-auth/src/auth-config-file.ts | 68 + packages/workers-auth/src/callback-server.ts | 201 +++ packages/workers-auth/src/context.ts | 74 + packages/workers-auth/src/env-vars.ts | 107 ++ packages/workers-auth/src/errors.ts | 185 +++ packages/workers-auth/src/flow.ts | 346 +++++ .../workers-auth/src/generate-auth-url.ts | 36 + .../workers-auth/src/generate-random-state.ts | 17 + packages/workers-auth/src/index.ts | 80 + packages/workers-auth/src/pkce.ts | 78 + packages/workers-auth/src/state.ts | 114 ++ .../workers-auth/src/test-helpers/index.ts | 2 + .../src/test-helpers/msw-handlers}/access.ts | 2 +- .../src/test-helpers/msw-handlers}/oauth.ts | 0 packages/workers-auth/src/token-exchange.ts | 359 +++++ .../tests}/access.test.ts | 154 +- packages/workers-auth/tests/tsconfig.json | 9 + packages/workers-auth/tests/vitest.global.ts | 8 + packages/workers-auth/tests/vitest.setup.ts | 34 + packages/workers-auth/tsconfig.json | 10 + packages/workers-auth/tsup.config.ts | 20 + packages/workers-auth/turbo.json | 16 + packages/workers-auth/vitest.config.mts | 15 + packages/wrangler/e2e/auth-scopes.test.ts | 6 +- packages/wrangler/package.json | 1 + .../src/__tests__/helpers/mock-http-server.ts | 5 + .../src/__tests__/helpers/msw/index.ts | 6 +- packages/wrangler/src/user/access.ts | 155 +- packages/wrangler/src/user/auth-variables.ts | 127 +- .../wrangler/src/user/generate-auth-url.ts | 40 +- .../src/user/generate-random-state.ts | 22 +- packages/wrangler/src/user/user.ts | 1355 ++--------------- pnpm-lock.yaml | 43 + .../__tests__/validate-changesets.test.ts | 1 + 42 files changed, 2688 insertions(+), 1604 deletions(-) create mode 100644 .changeset/extract-workers-auth-oauth-flow.md create mode 100644 packages/workers-auth/AGENTS.md create mode 100644 packages/workers-auth/LICENSE-APACHE create mode 100644 packages/workers-auth/LICENSE-MIT create mode 100644 packages/workers-auth/README.md create mode 100644 packages/workers-auth/package.json create mode 100644 packages/workers-auth/scripts/deps.ts create mode 100644 packages/workers-auth/src/access.ts create mode 100644 packages/workers-auth/src/auth-config-file.ts create mode 100644 packages/workers-auth/src/callback-server.ts create mode 100644 packages/workers-auth/src/context.ts create mode 100644 packages/workers-auth/src/env-vars.ts create mode 100644 packages/workers-auth/src/errors.ts create mode 100644 packages/workers-auth/src/flow.ts create mode 100644 packages/workers-auth/src/generate-auth-url.ts create mode 100644 packages/workers-auth/src/generate-random-state.ts create mode 100644 packages/workers-auth/src/index.ts create mode 100644 packages/workers-auth/src/pkce.ts create mode 100644 packages/workers-auth/src/state.ts create mode 100644 packages/workers-auth/src/test-helpers/index.ts rename packages/{wrangler/src/__tests__/helpers/msw/handlers => workers-auth/src/test-helpers/msw-handlers}/access.ts (95%) rename packages/{wrangler/src/__tests__/helpers/msw/handlers => workers-auth/src/test-helpers/msw-handlers}/oauth.ts (100%) create mode 100644 packages/workers-auth/src/token-exchange.ts rename packages/{wrangler/src/__tests__ => workers-auth/tests}/access.test.ts (63%) create mode 100644 packages/workers-auth/tests/tsconfig.json create mode 100644 packages/workers-auth/tests/vitest.global.ts create mode 100644 packages/workers-auth/tests/vitest.setup.ts create mode 100644 packages/workers-auth/tsconfig.json create mode 100644 packages/workers-auth/tsup.config.ts create mode 100644 packages/workers-auth/turbo.json create mode 100644 packages/workers-auth/vitest.config.mts diff --git a/.changeset/extract-workers-auth-oauth-flow.md b/.changeset/extract-workers-auth-oauth-flow.md new file mode 100644 index 0000000000..c27549656e --- /dev/null +++ b/.changeset/extract-workers-auth-oauth-flow.md @@ -0,0 +1,20 @@ +--- +"wrangler": patch +"@cloudflare/workers-auth": patch +--- + +Extract the OAuth 2.0 + PKCE flow into a new `@cloudflare/workers-auth` package. + +The OAuth login / logout / refresh logic, the auth-config TOML file IO, the OAuth token exchange + local callback server, and the Cloudflare Access detection helpers that previously lived in `packages/wrangler/src/user/` have moved to the new internal-only `@cloudflare/workers-auth` package. Wrangler now wires the OAuth flow up via a small glue module that injects its logger, browser opener, interactivity detector, and config cache via a dependency- injection context. + +What stays in wrangler: + +- The yargs `login` / `logout` / `whoami` / `auth token` commands +- Environment-based credential resolution (`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_API_KEY` / `CLOUDFLARE_EMAIL`, etc.) +- Cloudflare account selection (`requireAuth`, `getOrSelectAccountId`) +- The OAuth scope catalog (passed into the OAuth flow as a generic `string[]`) +- `whoami` / account fetching + +No behavior change for end users. The on-disk TOML format and location remain identical, and all telemetry message labels are preserved verbatim. + +`@cloudflare/workers-auth` is published with `prerelease: true` and is not intended for external use — its APIs may change without notice. diff --git a/packages/workers-auth/AGENTS.md b/packages/workers-auth/AGENTS.md new file mode 100644 index 0000000000..62609c34b1 --- /dev/null +++ b/packages/workers-auth/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md — workers-auth + +OAuth-2.0-with-PKCE flow against Cloudflare's `dash.cloudflare.com` (or staging / +custom-overridden) endpoints. Used by wrangler and (in future) other Cloudflare +CLIs. Internal-only — published as `prerelease: true`. + +## STRUCTURE + +- `src/pkce.ts` — PKCE code-verifier / code-challenge generation (RFC 7636) +- `src/errors.ts` — `ErrorOAuth2` class hierarchy + `toErrorClass` mapper +- `src/generate-auth-url.ts` — authorize URL builder + `OAUTH_CALLBACK_URL` +- `src/generate-random-state.ts` — CSRF state generator +- `src/env-vars.ts` — `WRANGLER_*` env-var getters for OAuth endpoints +- `src/access.ts` — Cloudflare Access detection + service-token / `cloudflared` headers +- `src/auth-config-file.ts` — read/write the persisted TOML at `/config/.toml` +- `src/state.ts` — `readStoredAuthState()` + `StoredAuthState` shape +- `src/token-exchange.ts` — auth-code → token + refresh-token rotation + `fetchAuthToken` +- `src/callback-server.ts` — local HTTP server on `localhost:8976` for the OAuth callback +- `src/flow.ts` — `createOAuthFlow(ctx)` factory wiring everything together +- `src/context.ts` — `OAuthFlowContext` interface (DI surface) +- `src/test-helpers/` — MSW handlers for consumers' tests (`@cloudflare/workers-auth/test-helpers`) + +## DI SURFACE + +`createOAuthFlow(ctx)` accepts a context object: + +- `logger` — drop-in replacement for wrangler's logger singleton +- `isNonInteractiveOrCI()` — whether to suppress interactive prompts +- `openInBrowser(url)` — opens the browser to the OAuth authorize URL +- `hasEnvCredentials()` — short-circuits refresh logic when env-based auth is set +- `purgeOnLoginOrLogout()` — invalidate consumer-side caches after login/logout +- `generateAuthUrl?` / `generateRandomState?` — test overrides for deterministic + snapshot tests (defaults pull from `./generate-auth-url` / `./generate-random-state`) + +Wrangler wires this once in `packages/wrangler/src/user/user.ts`. + +## CONVENTIONS + +- License: dual MIT/Apache-2.0. Files derived from + [BitySA/oauth2-auth-code-pkce](https://github.com/BitySA/oauth2-auth-code-pkce) + carry the Apache-2.0 header. +- No `console.*` — use the injected `ctx.logger`. +- No global `fetch` — use undici's `fetch`. +- `UserError` instances must carry stable `telemetryMessage` labels + (` `, e.g. `user oauth invalid scope`). + These labels are part of the telemetry contract — preserve them verbatim. +- No direct Cloudflare REST API calls. This package talks to OAuth endpoints + (`/oauth2/auth`, `/oauth2/token`, `/oauth2/revoke`) only. +- OAuth callback server listens on `localhost:8976` by default; override via + `LoginProps.callbackHost` / `callbackPort` per-call. + +## BUILD + +- tsup: two entry points — `src/index.ts` and `src/test-helpers/index.ts` +- ESM-only output to `dist/` +- `@cloudflare/*`, `undici`, `msw`, and `vitest` are kept external diff --git a/packages/workers-auth/LICENSE-APACHE b/packages/workers-auth/LICENSE-APACHE new file mode 100644 index 0000000000..1b5ec8b78e --- /dev/null +++ b/packages/workers-auth/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/packages/workers-auth/LICENSE-MIT b/packages/workers-auth/LICENSE-MIT new file mode 100644 index 0000000000..a0e7ebf133 --- /dev/null +++ b/packages/workers-auth/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2020 Cloudflare, Inc. + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/packages/workers-auth/README.md b/packages/workers-auth/README.md new file mode 100644 index 0000000000..3dac26aee1 --- /dev/null +++ b/packages/workers-auth/README.md @@ -0,0 +1,31 @@ +# @cloudflare/workers-auth + +Internal OAuth 2.0 + PKCE flow for Cloudflare CLIs (wrangler and friends). + +> **Not intended for external use.** APIs may change without notice. This package +> is consumed only by other packages inside this monorepo. + +## What's in this package + +- OAuth 2.0 Authorization Code flow with PKCE (RFC 6749 + RFC 7636) +- Cloudflare-flavored OAuth endpoints (`dash.cloudflare.com` / staging / `WRANGLER_*` overrides) +- Cloudflare Access detection + service-token / `cloudflared` integration for staging auth domains +- Persistent auth state stored as TOML in `/config/.toml` +- `login`, `logout`, `loginOrRefreshIfRequired`, `getOauthToken`, `getOAuthTokenFromLocalState` + +What's **not** here (lives in wrangler): + +- yargs `login` / `logout` / `whoami` / `auth token` commands +- Environment-based credential resolution (`CLOUDFLARE_API_TOKEN`, etc.) +- Cloudflare account selection (`requireAuth`, `getOrSelectAccountId`) +- The scope catalog (passed in as `string[]`) +- `whoami` / account fetching + +## Attribution + +Portions of this package are derived from +[BitySA/oauth2-auth-code-pkce](https://github.com/BitySA/oauth2-auth-code-pkce), +licensed under the Apache License 2.0. See the individual file headers for +the attribution and `LICENSE` for the full text. + +The rest of the package is MIT-licensed. diff --git a/packages/workers-auth/package.json b/packages/workers-auth/package.json new file mode 100644 index 0000000000..8ecd694477 --- /dev/null +++ b/packages/workers-auth/package.json @@ -0,0 +1,72 @@ +{ + "name": "@cloudflare/workers-auth", + "version": "0.0.0", + "description": "Internal OAuth 2.0 + PKCE flow for Cloudflare CLIs. Not intended for external use — APIs may change without notice.", + "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/workers-auth#readme", + "bugs": { + "url": "https://github.com/cloudflare/workers-sdk/issues" + }, + "license": "MIT OR Apache-2.0", + "author": "workers-devprod@cloudflare.com", + "repository": { + "type": "git", + "url": "https://github.com/cloudflare/workers-sdk.git", + "directory": "packages/workers-auth" + }, + "files": [ + "dist" + ], + "sideEffects": false, + "exports": { + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./test-helpers": { + "import": "./dist/test-helpers/index.mjs", + "types": "./dist/test-helpers/index.d.mts" + } + }, + "scripts": { + "build": "tsup", + "check:type": "tsc -p ./tsconfig.json", + "deploy": "echo 'no deploy'", + "dev": "concurrently -c black,blue --kill-others-on-fail false \"pnpm tsup --watch src\" \"pnpm run check:type --watch --preserveWatchOutput\"", + "test": "vitest", + "test:ci": "vitest run" + }, + "dependencies": { + "@cloudflare/workers-utils": "workspace:*", + "undici": "catalog:default" + }, + "devDependencies": { + "@cloudflare/workers-tsconfig": "workspace:*", + "@types/node": "catalog:default", + "@vitest/ui": "catalog:default", + "concurrently": "^8.2.2", + "msw": "catalog:default", + "smol-toml": "catalog:default", + "ts-dedent": "^2.2.0", + "tsup": "8.3.0", + "typescript": "catalog:default", + "vitest": "catalog:default" + }, + "peerDependencies": { + "msw": "catalog:default", + "vitest": "^4.1.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vitest": { + "optional": true + } + }, + "volta": { + "extends": "../../package.json" + }, + "workers-sdk": { + "prerelease": true + } +} diff --git a/packages/workers-auth/scripts/deps.ts b/packages/workers-auth/scripts/deps.ts new file mode 100644 index 0000000000..38e3ff05b5 --- /dev/null +++ b/packages/workers-auth/scripts/deps.ts @@ -0,0 +1,15 @@ +/** + * Dependencies that _are not_ bundled along with @cloudflare/workers-auth. + * + * These must be explicitly documented with a reason why they cannot be bundled. + * This list is validated by `tools/deployments/validate-package-dependencies.ts`. + */ +export const EXTERNAL_DEPENDENCIES = [ + // Bundling `undici` would produce a duplicate copy in every downstream + // consumer that already depends on undici (e.g. wrangler), which breaks + // `instanceof Request`/`Response`/`Headers` checks across the boundary + // and prevents `setGlobalDispatcher` / proxy configuration from applying + // to the bundled copy. Keeping it external lets the package manager + // deduplicate undici to a single shared instance. + "undici", +]; diff --git a/packages/workers-auth/src/access.ts b/packages/workers-auth/src/access.ts new file mode 100644 index 0000000000..2856f0f5ed --- /dev/null +++ b/packages/workers-auth/src/access.ts @@ -0,0 +1,201 @@ +import { spawnSync } from "node:child_process"; +import { UserError } from "@cloudflare/workers-utils"; +import { fetch } from "undici"; +import { + getAccessClientIdFromEnv, + getAccessClientSecretFromEnv, + getAuthDomainFromEnv, + getCfAuthorizationTokenFromEnv, +} from "./env-vars"; +import type { OAuthFlowLogger } from "./context"; + +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(); +} + +/** + * Probe a domain to detect whether it is sitting behind Cloudflare Access. + * + * A 302 to `cloudflareaccess.com` is the canonical signal. Service-auth-only + * Access applications return a hard 403 instead and are therefore not detected + * here — see {@link getAccessHeaders} for how this is handled. + */ +export async function domainUsesAccess( + domain: string, + logger: OAuthFlowLogger +): Promise { + logger.debug("Checking if domain has Access enabled:", domain); + + if (usesAccessCache.has(domain)) { + logger.debug( + "Using cached Access switch for:", + domain, + usesAccessCache.get(domain) + ); + return usesAccessCache.get(domain) ?? false; + } + logger.debug("Access switch not cached for:", domain); + try { + const controller = new AbortController(); + const cancel = setTimeout(() => { + controller.abort(); + }, 1000); + + const output = await fetch(`https://${domain}`, { + redirect: "manual", + signal: controller.signal, + }); + clearTimeout(cancel); + const usesAccess = !!( + output.status === 302 && + output.headers.get("location")?.includes("cloudflareaccess.com") + ); + logger.debug("Caching access switch for:", domain); + + usesAccessCache.set(domain, usesAccess); + return usesAccess; + } catch { + usesAccessCache.set(domain, false); + return false; + } +} + +/** + * Get the headers needed to authenticate with an Access-protected domain. + * + * @param domain The hostname of the Access-protected domain (e.g. `"example.com"`). + * @param options logger + an `isNonInteractiveOrCI` predicate used to + * produce an actionable error in CI; both default to no-op / `false`. + * @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, + options: { + logger: OAuthFlowLogger; + isNonInteractiveOrCI: () => boolean; + } +): Promise> { + const logger = options.logger; + const isNonInteractiveOrCI = options.isNonInteractiveOrCI; + + // 1. If Access Service Token credentials are provided, use them directly. + // + // This check intentionally comes before `domainUsesAccess()`, which detects + // Access by looking for a 302 redirect to `cloudflareaccess.com`. When an + // Access application is configured to only allow Service Auth tokens (no + // interactive user authentication), the domain responds with a hard 403 + // instead of redirecting, so `domainUsesAccess()` returns false. If we + // gated the env var check on `domainUsesAccess()` we would never attach + // the service token headers and the request would fail with a 403. + 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.` + ); + } + + if (!(await domainUsesAccess(domain, logger))) { + return {}; + } + logger.debug("Getting Access headers for domain:", domain); + if (headersCache[domain]) { + logger.debug("Using cached Access headers for domain:", domain); + return headersCache[domain]; + } + + // 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/`, + { + telemetryMessage: "user access missing service token non interactive", + } + ); + } + + // 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) { + 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", + { telemetryMessage: "user access missing cloudflared" } + ); + } + const stringOutput = output.stdout.toString(); + logger.debug("cloudflared output:", stringOutput); + const matches = stringOutput.match(/fetched your token:\n\n(.*)/m); + if (matches && matches.length >= 2) { + 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"); +} + +/** + * Get headers needed to authenticate with the Cloudflare OAuth auth domain + * (the OAuth `WRANGLER_AUTH_DOMAIN`, which is `dash.cloudflare.com` by default + * and `dash.staging.cloudflare.com` in staging). + * + * Checks `WRANGLER_CF_AUTHORIZATION_TOKEN` first, then falls back to + * {@link getAccessHeaders} against the configured auth domain. + */ +export async function getCloudflareAccessHeaders(options: { + logger: OAuthFlowLogger; + isNonInteractiveOrCI: () => boolean; +}): Promise> { + const cfAuthToken = getCfAuthorizationTokenFromEnv(); + + // If the environment variable is defined, go ahead and use it. + if (cfAuthToken !== undefined) { + // Don't include the token value in the log — if debug logging is enabled + // and logs are persisted, the raw token would leak as a credential. + options.logger.debug( + "Using WRANGLER_CF_AUTHORIZATION_TOKEN from environment" + ); + return { Cookie: `CF_Authorization=${cfAuthToken}` }; + } + + return getAccessHeaders(getAuthDomainFromEnv(), options); +} diff --git a/packages/workers-auth/src/auth-config-file.ts b/packages/workers-auth/src/auth-config-file.ts new file mode 100644 index 0000000000..4f10c02e95 --- /dev/null +++ b/packages/workers-auth/src/auth-config-file.ts @@ -0,0 +1,68 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { + getCloudflareApiEnvironmentFromEnv, + getGlobalWranglerConfigPath, + parseTOML, + readFileSync, +} from "@cloudflare/workers-utils"; +import TOML from "smol-toml"; + +/** + * The data that may be read from the on-disk user auth config file. + */ +export interface UserAuthConfig { + oauth_token?: string; + refresh_token?: string; + expiration_time?: string; + scopes?: string[]; + /** @deprecated - this field was only provided by the deprecated v1 `wrangler config` command. */ + api_token?: string; +} + +/** + * The path to the config file that holds user authentication data, + * relative to the user's home directory. + */ +const USER_AUTH_CONFIG_PATH = "config"; + +/** + * Returns the absolute path to the auth config TOML file. + * + * The file lives under the global Wrangler config directory and is named + * `default.toml` in production, or `.toml` for the staging / + * other Cloudflare API environments. + */ +export function getAuthConfigFilePath(): string { + const environment = getCloudflareApiEnvironmentFromEnv(); + const filePath = `${USER_AUTH_CONFIG_PATH}/${environment === "production" ? "default.toml" : `${environment}.toml`}`; + return path.join(getGlobalWranglerConfigPath(), filePath); +} + +/** + * Writes the user auth config to disk. + * + * No in-memory cache to invalidate — auth state is read on demand by every call + * site that needs it. Callers are responsible for any consumer-side cache + * purging (e.g. via the {@link OAuthFlowContext.purgeOnLoginOrLogout} hook). + */ +export function writeAuthConfigFile(config: UserAuthConfig): void { + const configPath = getAuthConfigFilePath(); + + mkdirSync(path.dirname(configPath), { + recursive: true, + }); + writeFileSync(configPath, TOML.stringify(config), { + encoding: "utf-8", + }); +} + +/** + * Reads the user auth config from disk. + * + * @throws if the file does not exist or cannot be parsed as TOML. Callers + * typically catch this and treat the failure as "not logged in via local OAuth". + */ +export function readAuthConfigFile(): UserAuthConfig { + return parseTOML(readFileSync(getAuthConfigFilePath())) as UserAuthConfig; +} diff --git a/packages/workers-auth/src/callback-server.ts b/packages/workers-auth/src/callback-server.ts new file mode 100644 index 0000000000..e275c7f5a0 --- /dev/null +++ b/packages/workers-auth/src/callback-server.ts @@ -0,0 +1,201 @@ +/* Based heavily on code from https://github.com/BitySA/oauth2-auth-code-pkce + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import assert from "node:assert"; +import http from "node:http"; +import url from "node:url"; +import { UserError } from "@cloudflare/workers-utils"; +import { ErrorAccessDenied, ErrorNoAuthCode } from "./errors"; +import { + exchangeAuthCodeForAccessToken, + getAuthURL, + isReturningFromAuthServer, + type AccessContext, +} from "./token-exchange"; +import type { OAuthFlowContext } from "./context"; +import type { generateAuthUrl as defaultGenerateAuthUrl } from "./generate-auth-url"; +import type { generateRandomState as defaultGenerateRandomState } from "./generate-random-state"; +import type { OAuthFlowState } from "./state"; + +export interface GetOauthTokenOptions { + browser: boolean; + scopes: string[]; + clientId: string; + denied: { + url: string; + error: string; + }; + granted: { + url: string; + }; + callbackHost: string; + callbackPort: number; +} + +/** + * Orchestrate the interactive OAuth login: spin up a local HTTP callback + * server, open the authorize URL in the user's browser, and wait for the + * server to redeem the auth code for an access token. Times out after 2 + * minutes. + */ +export async function getOauthToken( + options: GetOauthTokenOptions, + state: OAuthFlowState, + ctx: OAuthFlowContext, + generators: { + generateAuthUrl: typeof defaultGenerateAuthUrl; + generateRandomState: typeof defaultGenerateRandomState; + } +): Promise { + const urlToOpen = await getAuthURL( + options.scopes, + options.clientId, + state, + generators + ); + let server: http.Server; + let loginTimeoutHandle: ReturnType; + const timerPromise = new Promise((_, reject) => { + loginTimeoutHandle = setTimeout(() => { + server.close(); + clearTimeout(loginTimeoutHandle); + reject( + new UserError( + "Timed out waiting for authorization code, please try again.", + { telemetryMessage: "user oauth authorization timeout" } + ) + ); + }, 120000); // wait for 120 seconds for the user to authorize + }); + + const loginPromise = new Promise((resolve, reject) => { + server = http.createServer(async (req, res) => { + function finish(token: null, error: Error): void; + function finish(token: AccessContext): void; + function finish(token: AccessContext | null, error?: Error) { + clearTimeout(loginTimeoutHandle); + server.close((closeErr?: Error) => { + if (error || closeErr) { + reject(error || closeErr); + } else { + assert(token); + resolve(token); + } + }); + } + + assert(req.url, "This request doesn't have a URL"); // This should never happen + const { pathname, query } = url.parse(req.url, true); + if (req.method !== "GET") { + return res.end("OK"); + } + switch (pathname) { + case "/oauth/callback": { + let hasAuthCode = false; + try { + hasAuthCode = isReturningFromAuthServer(query, state, ctx.logger); + } catch (err: unknown) { + if (err instanceof ErrorAccessDenied) { + res.writeHead(307, { + Location: options.denied.url, + }); + res.end(() => { + finish( + null, + new UserError(options.denied.error, { + telemetryMessage: "user oauth consent denied", + }) + ); + }); + + return; + } else { + finish(null, err as Error); + return; + } + } + if (!hasAuthCode) { + // render an error page here + finish( + null, + new ErrorNoAuthCode("", { + telemetryMessage: "user oauth missing auth code", + }) + ); + return; + } else { + // `exchangeAuthCodeForAccessToken` can reject (network error, + // invalid JSON, OAuth error response, etc.). Without this + // `try/catch` the rejection would become an unhandled promise + // rejection inside an `http.createServer` callback, which is + // not promise-aware — Node.js >= 15 terminates the process on + // unhandled rejection by default. Route the error through + // `finish` so the caller's promise rejects cleanly. + try { + const exchange = await exchangeAuthCodeForAccessToken( + state, + ctx.logger, + ctx.isNonInteractiveOrCI + ); + res.writeHead(307, { + Location: options.granted.url, + }); + res.end(() => { + finish(exchange); + }); + } catch (err) { + finish(null, err as Error); + } + return; + } + } + } + }); + + if (options.callbackHost !== "localhost" || options.callbackPort !== 8976) { + ctx.logger.log( + `Temporary login server listening on ${options.callbackHost}:${options.callbackPort}` + ); + ctx.logger.log( + "Note that the OAuth login page will always redirect to `localhost:8976`.\n" + + "If you have changed the callback host or port because you are running in a container, then ensure that you have port forwarding set up correctly." + ); + } + // Surface a clear error when the port is already in use (or any other + // `server.listen` failure) rather than crashing with an unhelpful + // stack trace from an unhandled 'error' event. + server.once("error", (err: NodeJS.ErrnoException) => { + clearTimeout(loginTimeoutHandle); + if (err.code === "EADDRINUSE") { + reject( + new UserError( + `The OAuth callback server could not bind to ${options.callbackHost}:${options.callbackPort} because the port is already in use. Stop the process using that port or pass a different \`--callback-port\`.`, + { telemetryMessage: "user oauth callback port in use" } + ) + ); + } else { + reject(err); + } + }); + server.listen(options.callbackPort, options.callbackHost); + }); + if (options.browser) { + ctx.logger.log(`Opening a link in your default browser: ${urlToOpen}`); + await ctx.openInBrowser(urlToOpen); + } else { + ctx.logger.log(`Visit this link to authenticate: ${urlToOpen}`); + } + + return Promise.race([timerPromise, loginPromise]); +} diff --git a/packages/workers-auth/src/context.ts b/packages/workers-auth/src/context.ts new file mode 100644 index 0000000000..826228ff11 --- /dev/null +++ b/packages/workers-auth/src/context.ts @@ -0,0 +1,74 @@ +import type { + generateAuthUrl as defaultGenerateAuthUrl, + OAUTH_CALLBACK_URL, +} from "./generate-auth-url"; +import type { generateRandomState as defaultGenerateRandomState } from "./generate-random-state"; + +/** + * Subset of the wrangler `logger` singleton used by the OAuth flow. + * Consumers pass in an implementation that maps to their own logging surface. + */ +export interface OAuthFlowLogger { + debug(...args: unknown[]): void; + info(...args: unknown[]): void; + log(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; +} + +/** + * Dependency-injection surface for {@link createOAuthFlow}. + * + * The OAuth flow only talks to OAuth endpoints (`/oauth2/auth`, `/oauth2/token`, + * `/oauth2/revoke`) using `undici`'s `fetch` directly — there is no Cloudflare + * API client wired into this context. + */ +export interface OAuthFlowContext { + logger: OAuthFlowLogger; + + /** + * Whether the process should not prompt the user. The OAuth flow uses this to + * decide whether to short-circuit interactive login attempts. + */ + isNonInteractiveOrCI: () => boolean; + + /** + * Open the given URL in the user's default browser. Called during the + * interactive OAuth login flow with the authorize URL. + */ + openInBrowser: (url: string) => Promise; + + /** + * Whether environment-based credentials are present. When `true`, the OAuth + * flow short-circuits because the env credentials take priority over stored + * OAuth tokens: + * - `login` refuses to start + * - `logout` is a no-op (it cannot revoke env credentials) + * - the refresh check returns `false` so an expired stored OAuth token does + * not trigger a needless refresh attempt + */ + hasEnvCredentials: () => boolean; + + /** + * Called after a successful `login` or `logout` so the consumer can invalidate + * any caches that depend on the active token (e.g. wrangler's selected-account + * cache). + */ + purgeOnLoginOrLogout?: () => void; + + /** + * Override the OAuth authorize URL generator. Used by tests to produce a + * deterministic URL for snapshot testing. Defaults to the standard + * implementation. + */ + generateAuthUrl?: typeof defaultGenerateAuthUrl; + + /** + * Override the random state generator. Used by tests to produce a + * deterministic state value for snapshot testing. Defaults to the standard + * implementation. + */ + generateRandomState?: typeof defaultGenerateRandomState; +} + +export type { OAUTH_CALLBACK_URL }; diff --git a/packages/workers-auth/src/env-vars.ts b/packages/workers-auth/src/env-vars.ts new file mode 100644 index 0000000000..fb6c97f7cb --- /dev/null +++ b/packages/workers-auth/src/env-vars.ts @@ -0,0 +1,107 @@ +import { + getCloudflareApiEnvironmentFromEnv, + getEnvironmentVariableFactory, +} from "@cloudflare/workers-utils"; + +/** + * `WRANGLER_CLIENT_ID` is a UUID that is used to identify Wrangler + * to the Cloudflare APIs. + * + * Normally you should not need to set this explicitly. + * If you want to switch to the staging environment set the + * `WRANGLER_API_ENVIRONMENT=staging` environment variable instead. + */ +export const getClientIdFromEnv = getEnvironmentVariableFactory({ + variableName: "WRANGLER_CLIENT_ID", + defaultValue: () => + getCloudflareApiEnvironmentFromEnv() === "staging" + ? "4b2ea6cc-9421-4761-874b-ce550e0e3def" + : "54d11594-84e4-41aa-b438-e81b8fa78ee7", +}); + +/** + * `WRANGLER_AUTH_DOMAIN` is the URL base domain that is used + * to access OAuth URLs for the Cloudflare APIs. + * + * Normally you should not need to set this explicitly. + * If you want to switch to the staging environment set the + * `WRANGLER_API_ENVIRONMENT=staging` environment variable instead. + */ +export const getAuthDomainFromEnv = getEnvironmentVariableFactory({ + variableName: "WRANGLER_AUTH_DOMAIN", + defaultValue: () => + getCloudflareApiEnvironmentFromEnv() === "staging" + ? "dash.staging.cloudflare.com" + : "dash.cloudflare.com", +}); + +/** + * `WRANGLER_AUTH_URL` is the path that is used to access OAuth + * for the Cloudflare APIs. + * + * Normally you should not need to set this explicitly. + * If you want to switch to the staging environment set the + * `WRANGLER_API_ENVIRONMENT=staging` environment variable instead. + */ +export const getAuthUrlFromEnv = getEnvironmentVariableFactory({ + variableName: "WRANGLER_AUTH_URL", + defaultValue: () => `https://${getAuthDomainFromEnv()}/oauth2/auth`, +}); + +/** + * `WRANGLER_TOKEN_URL` is the path that is used to exchange an OAuth + * token for an API token. + * + * Normally you should not need to set this explicitly. + * If you want to switch to the staging environment set the + * `WRANGLER_API_ENVIRONMENT=staging` environment variable instead. + */ +export const getTokenUrlFromEnv = getEnvironmentVariableFactory({ + variableName: "WRANGLER_TOKEN_URL", + defaultValue: () => `https://${getAuthDomainFromEnv()}/oauth2/token`, +}); + +/** + * `WRANGLER_REVOKE_URL` is the path that is used to exchange an OAuth + * refresh token for a new OAuth token. + * + * Normally you should not need to set this explicitly. + * If you want to switch to the staging environment set the + * `WRANGLER_API_ENVIRONMENT=staging` environment variable instead. + */ +export const getRevokeUrlFromEnv = getEnvironmentVariableFactory({ + variableName: "WRANGLER_REVOKE_URL", + defaultValue: () => `https://${getAuthDomainFromEnv()}/oauth2/revoke`, +}); + +/** + * `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", +}); + +/** + * `WRANGLER_CF_AUTHORIZATION_TOKEN` is an explicit `CF_Authorization` cookie value + * used to authenticate against the OAuth auth domain when it is Access-protected + * (typically staging). When set, the OAuth flow skips Access detection and uses + * this token directly. + */ +export const getCfAuthorizationTokenFromEnv = getEnvironmentVariableFactory({ + variableName: "WRANGLER_CF_AUTHORIZATION_TOKEN", +}); diff --git a/packages/workers-auth/src/errors.ts b/packages/workers-auth/src/errors.ts new file mode 100644 index 0000000000..450f12b9c6 --- /dev/null +++ b/packages/workers-auth/src/errors.ts @@ -0,0 +1,185 @@ +/* Based heavily on code from https://github.com/BitySA/oauth2-auth-code-pkce + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { UserError } from "@cloudflare/workers-utils"; + +/** + * A list of OAuth2AuthCodePKCE errors. + */ +// To "namespace" all errors. +export class ErrorOAuth2 extends UserError { + toString(): string { + return "ErrorOAuth2"; + } +} + +// Unclassified Oauth errors +export class ErrorUnknown extends UserError { + toString(): string { + return "ErrorUnknown"; + } +} + +// Some generic, internal errors that can happen. +export class ErrorNoAuthCode extends ErrorOAuth2 { + toString(): string { + return "ErrorNoAuthCode"; + } +} +export class ErrorInvalidReturnedStateParam extends ErrorOAuth2 { + toString(): string { + return "ErrorInvalidReturnedStateParam"; + } +} +export class ErrorInvalidJson extends ErrorOAuth2 { + toString(): string { + return "ErrorInvalidJson"; + } +} + +// Errors that occur across many endpoints +export class ErrorInvalidScope extends ErrorOAuth2 { + toString(): string { + return "ErrorInvalidScope"; + } +} +export class ErrorInvalidRequest extends ErrorOAuth2 { + toString(): string { + return "ErrorInvalidRequest"; + } +} +export class ErrorInvalidToken extends ErrorOAuth2 { + toString(): string { + return "ErrorInvalidToken"; + } +} + +/** + * Possible authorization grant errors given by the redirection from the + * authorization server. + */ +export class ErrorAuthenticationGrant extends ErrorOAuth2 { + toString(): string { + return "ErrorAuthenticationGrant"; + } +} +export class ErrorUnauthorizedClient extends ErrorAuthenticationGrant { + toString(): string { + return "ErrorUnauthorizedClient"; + } +} +export class ErrorAccessDenied extends ErrorAuthenticationGrant { + toString(): string { + return "ErrorAccessDenied"; + } +} +export class ErrorUnsupportedResponseType extends ErrorAuthenticationGrant { + toString(): string { + return "ErrorUnsupportedResponseType"; + } +} +export class ErrorServerError extends ErrorAuthenticationGrant { + toString(): string { + return "ErrorServerError"; + } +} +export class ErrorTemporarilyUnavailable extends ErrorAuthenticationGrant { + toString(): string { + return "ErrorTemporarilyUnavailable"; + } +} + +/** + * A list of possible access token response errors. + */ +export class ErrorAccessTokenResponse extends ErrorOAuth2 { + toString(): string { + return "ErrorAccessTokenResponse"; + } +} +export class ErrorInvalidClient extends ErrorAccessTokenResponse { + toString(): string { + return "ErrorInvalidClient"; + } +} +export class ErrorInvalidGrant extends ErrorAccessTokenResponse { + toString(): string { + return "ErrorInvalidGrant"; + } +} +export class ErrorUnsupportedGrantType extends ErrorAccessTokenResponse { + toString(): string { + return "ErrorUnsupportedGrantType"; + } +} + +/** + * Translate the raw error strings returned from the server into error classes. + */ +export function toErrorClass(rawError: string): ErrorOAuth2 | ErrorUnknown { + switch (rawError) { + case "invalid_request": + return new ErrorInvalidRequest(rawError, { + telemetryMessage: "user oauth invalid request", + }); + case "invalid_grant": + return new ErrorInvalidGrant(rawError, { + telemetryMessage: "user oauth invalid grant", + }); + case "unauthorized_client": + return new ErrorUnauthorizedClient(rawError, { + telemetryMessage: "user oauth unauthorized client", + }); + case "access_denied": + return new ErrorAccessDenied(rawError, { + telemetryMessage: "user oauth access denied", + }); + case "unsupported_response_type": + return new ErrorUnsupportedResponseType(rawError, { + telemetryMessage: "user oauth unsupported response type", + }); + case "invalid_scope": + return new ErrorInvalidScope(rawError, { + telemetryMessage: "user oauth invalid scope", + }); + case "server_error": + return new ErrorServerError(rawError, { + telemetryMessage: "user oauth server error", + }); + case "temporarily_unavailable": + return new ErrorTemporarilyUnavailable(rawError, { + telemetryMessage: "user oauth temporarily unavailable", + }); + case "invalid_client": + return new ErrorInvalidClient(rawError, { + telemetryMessage: "user oauth invalid client", + }); + case "unsupported_grant_type": + return new ErrorUnsupportedGrantType(rawError, { + telemetryMessage: "user oauth unsupported grant type", + }); + case "invalid_json": + return new ErrorInvalidJson(rawError, { + telemetryMessage: "user oauth invalid json", + }); + case "invalid_token": + return new ErrorInvalidToken(rawError, { + telemetryMessage: "user oauth invalid token", + }); + default: + return new ErrorUnknown(rawError, { + telemetryMessage: "user oauth unknown error", + }); + } +} diff --git a/packages/workers-auth/src/flow.ts b/packages/workers-auth/src/flow.ts new file mode 100644 index 0000000000..5dcbae2ae8 --- /dev/null +++ b/packages/workers-auth/src/flow.ts @@ -0,0 +1,346 @@ +/* Based heavily on code from https://github.com/BitySA/oauth2-auth-code-pkce + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { rmSync } from "node:fs"; +import { + getCloudflareComplianceRegion, + UserError, +} from "@cloudflare/workers-utils"; +import dedent from "ts-dedent"; +import { fetch } from "undici"; +import { getAuthConfigFilePath, writeAuthConfigFile } from "./auth-config-file"; +import { getOauthToken } from "./callback-server"; +import { getClientIdFromEnv, getRevokeUrlFromEnv } from "./env-vars"; +import { + generateAuthUrl as defaultGenerateAuthUrl, + OAUTH_CALLBACK_URL, +} from "./generate-auth-url"; +import { generateRandomState as defaultGenerateRandomState } from "./generate-random-state"; +import { readStoredAuthState, type OAuthFlowState } from "./state"; +import { exchangeRefreshTokenForAccessToken } from "./token-exchange"; +import type { OAuthFlowContext } from "./context"; +import type { ComplianceConfig } from "@cloudflare/workers-utils"; + +/** + * Options for an interactive OAuth login. + */ +export interface LoginProps { + complianceConfig: ComplianceConfig; + /** + * The OAuth scopes to request. The catalog of valid scope keys is + * consumer-defined; this package treats scopes as opaque strings. + */ + scopes: string[]; + /** + * Whether to open the authorize URL in a browser. Defaults to `true`. + * When `false` the URL is printed for the user to copy/paste. + */ + browser?: boolean; + /** Host the local callback server listens on. Defaults to `localhost`. */ + callbackHost?: string; + /** Port the local callback server listens on. Defaults to `8976`. */ + callbackPort?: number; +} + +/** + * Public surface returned by {@link createOAuthFlow}. + */ +export interface OAuthFlowAPI { + /** + * Open the authorize URL in the user's browser, wait for the callback to be + * hit on the local HTTP server, exchange the code for an access token, and + * persist the result to disk. + * + * Refuses to start when `ctx.hasEnvCredentials()` returns `true`. + * Refuses to start when the compliance region is `fedramp_high`. + * + * @returns `true` on success, `false` when env credentials are present. + */ + login(props: LoginProps): Promise; + + /** + * Revoke the stored refresh token at the Cloudflare OAuth endpoint and + * delete the on-disk auth config file. + * + * No-op when `ctx.hasEnvCredentials()` returns `true` (env credentials + * cannot be revoked). + */ + logout(): Promise; + + /** + * If the user has no stored OAuth token, attempt an interactive login. + * If they have one but it is expired, attempt a refresh; if refresh fails, + * fall back to an interactive login. + * + * Scopes are required in case an interactive login is triggered — the + * consumer's scope catalog lives outside this package. + * + * @returns `true` when the user is logged in (or env credentials are + * present), `false` when interactive login was needed but skipped (e.g. + * non-interactive environment). + */ + loginOrRefreshIfRequired(props: LoginProps): Promise; + + /** + * Read the OAuth access token from local state, refreshing it first if + * needed. Returns `undefined` when there is no stored OAuth token or the + * refresh fails. + * + * This intentionally does NOT consult env credentials — callers that want + * env-or-OAuth resolution should check env first themselves. + */ + getOAuthTokenFromLocalState(): Promise; + + /** + * Whether the stored OAuth access token has expired and a refresh is + * required before it can be used. Returns `false` when env credentials are + * present (per `ctx.hasEnvCredentials`), because the stored OAuth state is + * not consulted in that case. + */ + isRefreshNeeded(): boolean; + + /** + * Trigger an OAuth refresh-token rotation. Persists the new access/refresh + * tokens to disk on success. Returns `false` on any failure. + */ + refreshToken(): Promise; +} + +/** + * Build an instance of the OAuth flow bound to the given context. + * + * The returned object owns module-private state (the transient OAuth flow + * state and the deprecated-v1 warning latch). In practice consumers create + * exactly one instance per process. + */ +export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI { + const oauthFlowState: OAuthFlowState = {}; + const generators = { + generateAuthUrl: ctx.generateAuthUrl ?? defaultGenerateAuthUrl, + generateRandomState: ctx.generateRandomState ?? defaultGenerateRandomState, + }; + + async function login(props: LoginProps): Promise { + if (ctx.hasEnvCredentials()) { + // Env credentials override any login details, so no point in allowing + // the user to login. + ctx.logger.error( + "You are logged in with an API Token. Unset the CLOUDFLARE_API_TOKEN in the " + + "environment to log in via OAuth." + ); + return false; + } + + const complianceRegion = getCloudflareComplianceRegion( + props.complianceConfig + ); + if (complianceRegion === "fedramp_high") { + const configurationSource = props.complianceConfig?.compliance_region + ? "`compliance_region` configuration property" + : "`CLOUDFLARE_API_ENVIRONMENT` environment variable"; + throw new UserError( + dedent` + OAuth login is not supported in the \`${complianceRegion}\` compliance region. + Please use a Cloudflare API token (\`CLOUDFLARE_API_TOKEN\` environment variable) or remove the ${configurationSource}. + `, + { + telemetryMessage: "user login unsupported compliance region", + } + ); + } + + ctx.logger.log("Attempting to login via OAuth..."); + + const oauth = await getOauthToken( + { + browser: props.browser ?? true, + scopes: props.scopes, + clientId: getClientIdFromEnv(), + denied: { + url: "https://welcome.developers.workers.dev/wrangler-oauth-consent-denied", + error: + "Error: Consent denied. You must grant consent to Wrangler in order to login.\n" + + "If you don't want to do this consider passing an API token via the `CLOUDFLARE_API_TOKEN` environment variable", + }, + granted: { + url: "https://welcome.developers.workers.dev/wrangler-oauth-consent-granted", + }, + callbackHost: props.callbackHost ?? "localhost", + callbackPort: props.callbackPort ?? 8976, + }, + oauthFlowState, + ctx, + generators + ); + + writeAuthConfigFile({ + oauth_token: oauth.token?.value ?? "", + expiration_time: oauth.token?.expiry, + refresh_token: oauth.refreshToken?.value, + scopes: oauth.scopes, + }); + + ctx.logger.log(`Successfully logged in.`); + + ctx.purgeOnLoginOrLogout?.(); + + return true; + } + + function isRefreshNeeded(): boolean { + if (ctx.hasEnvCredentials()) { + return false; + } + const { accessToken } = readStoredAuthState({ warningLogger: ctx.logger }); + return Boolean(accessToken && new Date() >= new Date(accessToken.expiry)); + } + + async function refreshToken(): Promise { + // `exchangeRefreshTokenForAccessToken` reads the refresh token fresh from + // disk on every call, so we always pick up the latest rotation written by a + // sibling Wrangler process. Refresh tokens are single-use, so a long-lived + // process such as `wrangler dev` would otherwise send a stale value and get + // a 401 from the token endpoint. + + try { + const { + token: { value: oauth_token, expiry: expiration_time } = { + value: "", + expiry: "", + }, + refreshToken: { value: refresh_token } = {}, + scopes, + } = await exchangeRefreshTokenForAccessToken( + ctx.logger, + ctx.isNonInteractiveOrCI + ); + writeAuthConfigFile({ + oauth_token, + expiration_time, + refresh_token, + scopes, + }); + return true; + } catch (e) { + ctx.logger.debug( + `Token refresh failed: ${e instanceof Error ? e.message : String(e)}` + ); + return false; + } + } + + async function loginOrRefreshIfRequired(props: LoginProps): Promise { + // If env credentials are present, the consumer's credential resolver + // will use those rather than the stored OAuth token, so we don't need + // to refresh or log in. + if (ctx.hasEnvCredentials()) { + return true; + } + // TODO: ask permission before opening browser + const stored = readStoredAuthState({ warningLogger: ctx.logger }); + if (!stored.accessToken && !stored.deprecatedApiToken) { + // Not logged in. + // If we are not interactive, we cannot ask the user to login + return !ctx.isNonInteractiveOrCI() && (await login(props)); + } else if (isRefreshNeeded()) { + // We're logged in, but the refresh token seems to have expired, + // so let's try to refresh it + const didRefresh = await refreshToken(); + if (didRefresh) { + // The token was refreshed, so we're done here + return true; + } else { + // If the refresh token isn't valid, then we ask the user to login again + return !ctx.isNonInteractiveOrCI() && (await login(props)); + } + } else { + return true; + } + } + + async function logout(): Promise { + if (ctx.hasEnvCredentials()) { + // Env credentials override any login details, so we cannot log out. + ctx.logger.log( + "You are logged in with an API Token. Unset the CLOUDFLARE_API_TOKEN in the " + + "environment to log out." + ); + return; + } + + const storedRefreshToken = readStoredAuthState({ + warningLogger: ctx.logger, + }).refreshToken; + if (!storedRefreshToken) { + ctx.logger.log("Not logged in, exiting..."); + return; + } + + const body = + `client_id=${encodeURIComponent(getClientIdFromEnv())}&` + + `token_type_hint=refresh_token&` + + `token=${encodeURIComponent(storedRefreshToken.value || "")}`; + + const response = await fetch(getRevokeUrlFromEnv(), { + method: "POST", + body, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + await response.text(); // blank text? would be nice if it was something meaningful + rmSync(getAuthConfigFilePath()); + ctx.logger.log(`Successfully logged out.`); + ctx.purgeOnLoginOrLogout?.(); + } + + async function getOAuthTokenFromLocalState(): Promise { + // Check if we have an OAuth token + let stored = readStoredAuthState({ warningLogger: ctx.logger }); + if (!stored.accessToken) { + return undefined; + } + + // If the token is expired, try to refresh it. + // Note: we deliberately check the expiry directly rather than going through + // `isRefreshNeeded()`, because this function is called from contexts that + // already know they want the OAuth token (not env credentials), and we + // don't want the env-credentials short-circuit to skip the refresh. + const expired = + stored.accessToken && new Date() >= new Date(stored.accessToken.expiry); + if (expired) { + const didRefresh = await refreshToken(); + if (!didRefresh) { + return undefined; + } + // Re-read after the refresh has persisted the new token to disk. + stored = readStoredAuthState({ warningLogger: ctx.logger }); + } + + return stored.accessToken?.value; + } + + return { + login, + logout, + loginOrRefreshIfRequired, + getOAuthTokenFromLocalState, + isRefreshNeeded, + refreshToken, + }; +} + +// Re-export the constant for callers that want to know about the redirect URI +// without depending on `./generate-auth-url`. +export { OAUTH_CALLBACK_URL }; diff --git a/packages/workers-auth/src/generate-auth-url.ts b/packages/workers-auth/src/generate-auth-url.ts new file mode 100644 index 0000000000..ba432112f5 --- /dev/null +++ b/packages/workers-auth/src/generate-auth-url.ts @@ -0,0 +1,36 @@ +interface GenerateAuthUrlProps { + authUrl: string; + clientId: string; + scopes: string[]; + stateQueryParam: string; + codeChallenge: string; +} + +export const OAUTH_CALLBACK_URL = "http://localhost:8976/oauth/callback"; + +/** + * Build the OAuth 2.0 authorize URL for the Cloudflare auth endpoint. + * + * Extracted from the rest of the OAuth flow so consumers (or tests) can + * substitute a deterministic implementation when a stable URL is needed + * (e.g. for snapshot testing). + */ +export const generateAuthUrl = ({ + authUrl, + clientId, + scopes, + stateQueryParam, + codeChallenge, +}: GenerateAuthUrlProps) => { + return ( + authUrl + + `?response_type=code&` + + `client_id=${encodeURIComponent(clientId)}&` + + `redirect_uri=${encodeURIComponent(OAUTH_CALLBACK_URL)}&` + + // we add offline_access manually for every request + `scope=${encodeURIComponent([...scopes, "offline_access"].join(" "))}&` + + `state=${stateQueryParam}&` + + `code_challenge=${encodeURIComponent(codeChallenge)}&` + + `code_challenge_method=S256` + ); +}; diff --git a/packages/workers-auth/src/generate-random-state.ts b/packages/workers-auth/src/generate-random-state.ts new file mode 100644 index 0000000000..6f2c584418 --- /dev/null +++ b/packages/workers-auth/src/generate-random-state.ts @@ -0,0 +1,17 @@ +import { webcrypto as crypto } from "node:crypto"; +import { PKCE_CHARSET } from "./pkce"; + +/** + * Generates random state to be passed for anti-csrf. + * + * Extracted from the rest of the OAuth flow so consumers (or tests) can + * substitute a deterministic implementation when a stable state value is + * needed (e.g. for snapshot testing). + */ +export function generateRandomState(lengthOfState: number): string { + const output = new Uint32Array(lengthOfState); + crypto.getRandomValues(output); + return Array.from(output) + .map((num: number) => PKCE_CHARSET[num % PKCE_CHARSET.length]) + .join(""); +} diff --git a/packages/workers-auth/src/index.ts b/packages/workers-auth/src/index.ts new file mode 100644 index 0000000000..3481ad4ea0 --- /dev/null +++ b/packages/workers-auth/src/index.ts @@ -0,0 +1,80 @@ +// Public surface of @cloudflare/workers-auth. +// +// Consumers typically wire up a single `createOAuthFlow(ctx)` instance and +// then call its methods. The pure helpers exported here are useful when the +// consumer needs to read/write the auth state directly (e.g. wrangler's +// `getAPIToken` resolver), or to inject deterministic implementations into +// tests. + +export { + getAuthConfigFilePath, + readAuthConfigFile, + writeAuthConfigFile, +} from "./auth-config-file"; +export type { UserAuthConfig } from "./auth-config-file"; + +export { + clearAccessCaches, + domainUsesAccess, + getAccessHeaders, + getCloudflareAccessHeaders, +} from "./access"; + +export type { OAuthFlowContext, OAuthFlowLogger } from "./context"; + +export { + getAccessClientIdFromEnv, + getAccessClientSecretFromEnv, + getAuthDomainFromEnv, + getAuthUrlFromEnv, + getCfAuthorizationTokenFromEnv, + getClientIdFromEnv, + getRevokeUrlFromEnv, + getTokenUrlFromEnv, +} from "./env-vars"; + +export { + ErrorAccessDenied, + ErrorAccessTokenResponse, + ErrorAuthenticationGrant, + ErrorInvalidClient, + ErrorInvalidGrant, + ErrorInvalidJson, + ErrorInvalidRequest, + ErrorInvalidReturnedStateParam, + ErrorInvalidScope, + ErrorInvalidToken, + ErrorNoAuthCode, + ErrorOAuth2, + ErrorServerError, + ErrorTemporarilyUnavailable, + ErrorUnauthorizedClient, + ErrorUnknown, + ErrorUnsupportedGrantType, + ErrorUnsupportedResponseType, + toErrorClass, +} from "./errors"; + +export { createOAuthFlow, OAUTH_CALLBACK_URL } from "./flow"; +export type { LoginProps, OAuthFlowAPI } from "./flow"; + +export { generateAuthUrl } from "./generate-auth-url"; + +export { generateRandomState } from "./generate-random-state"; + +export { + base64urlEncode, + generatePKCECodes, + PKCE_CHARSET, + RECOMMENDED_CODE_VERIFIER_LENGTH, + RECOMMENDED_STATE_LENGTH, +} from "./pkce"; +export type { PKCECodes } from "./pkce"; + +export { readStoredAuthState } from "./state"; +export type { + AccessToken, + OAuthFlowState, + RefreshToken, + StoredAuthState, +} from "./state"; diff --git a/packages/workers-auth/src/pkce.ts b/packages/workers-auth/src/pkce.ts new file mode 100644 index 0000000000..6f758c2211 --- /dev/null +++ b/packages/workers-auth/src/pkce.ts @@ -0,0 +1,78 @@ +/* Based heavily on code from https://github.com/BitySA/oauth2-auth-code-pkce + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { webcrypto as crypto } from "node:crypto"; +import { TextEncoder } from "node:util"; + +/** + * The maximum length for a code verifier for the best security we can offer. + * Please note the NOTE section of RFC 7636 § 4.1 - the length must be >= 43, + * but <= 128, **after** base64 url encoding. This means 32 code verifier bytes + * encoded will be 43 bytes, or 96 bytes encoded will be 128 bytes. So 96 bytes + * is the highest valid value that can be used. + */ +export const RECOMMENDED_CODE_VERIFIER_LENGTH = 96; + +/** + * A sensible length for the state's length, for anti-csrf. + */ +export const RECOMMENDED_STATE_LENGTH = 32; + +/** + * Character set to generate code verifier defined in rfc7636. + */ +export const PKCE_CHARSET = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + +export interface PKCECodes { + codeChallenge: string; + codeVerifier: string; +} + +/** + * Implements *base64url-encode* (RFC 4648 § 5) without padding, which is NOT + * the same as regular base64 encoding. + */ +export function base64urlEncode(value: string): string { + let base64 = btoa(value); + base64 = base64.replace(/\+/g, "-"); + base64 = base64.replace(/\//g, "_"); + base64 = base64.replace(/=/g, ""); + return base64; +} + +/** + * Generates a code_verifier and code_challenge, as specified in rfc7636. + */ +export async function generatePKCECodes(): Promise { + const output = new Uint32Array(RECOMMENDED_CODE_VERIFIER_LENGTH); + crypto.getRandomValues(output); + const codeVerifier = base64urlEncode( + Array.from(output) + .map((num: number) => PKCE_CHARSET[num % PKCE_CHARSET.length]) + .join("") + ); + const buffer = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(codeVerifier) + ); + const hash = new Uint8Array(buffer); + let binary = ""; + const hashLength = hash.byteLength; + for (let i = 0; i < hashLength; i++) { + binary += String.fromCharCode(hash[i]); + } + const codeChallenge = base64urlEncode(binary); + return { codeChallenge, codeVerifier }; +} diff --git a/packages/workers-auth/src/state.ts b/packages/workers-auth/src/state.ts new file mode 100644 index 0000000000..96fd896e33 --- /dev/null +++ b/packages/workers-auth/src/state.ts @@ -0,0 +1,114 @@ +import { + getAuthConfigFilePath, + readAuthConfigFile, + type UserAuthConfig, +} from "./auth-config-file"; +import type { OAuthFlowLogger } from "./context"; + +export interface RefreshToken { + value: string; +} + +export interface AccessToken { + value: string; + expiry: string; +} + +/** + * Transient state that is shared across the steps of a single OAuth login flow + * within one Wrangler command. This state is not file-backed; it lives only for + * the duration of an interactive login. + */ +export interface OAuthFlowState { + authorizationCode?: string; + codeChallenge?: string; + codeVerifier?: string; + hasAuthCodeBeenExchangedForAccessToken?: boolean; + stateQueryParam?: string; +} + +/** + * The auth state that is stored on disk in the user auth config file (TOML). + * Read on demand by {@link readStoredAuthState} — never cached at module scope + * so that environment variables loaded after import (e.g. from `.env`) take + * priority correctly. + */ +export interface StoredAuthState { + accessToken?: AccessToken; + refreshToken?: RefreshToken; + scopes?: string[]; + /** @deprecated - this field was only provided by the deprecated v1 `wrangler config` command. */ + deprecatedApiToken?: string; +} + +/** + * Latch shared at module scope so the deprecated-v1-api-token warning fires + * at most once per process. + */ +let hasWarnedAboutDeprecatedV1ApiToken = false; + +/** + * Reset the deprecated-v1-api-token warning latch. Exported for tests only. + */ +export function _resetDeprecatedV1ApiTokenWarningLatch(): void { + hasWarnedAboutDeprecatedV1ApiToken = false; +} + +/** + * Read the on-disk auth state. Called on demand from every site that needs the + * stored OAuth tokens or the deprecated v1 `api_token`, rather than being + * cached at module scope, so that environment-based credentials loaded after + * module import are honoured by the rest of wrangler. + * + * @return an empty object when no auth config file exists or the file cannot + * be parsed — the caller treats this as "not logged in via local OAuth". + * + * @param options.configOverride seed the state from an in-memory config (used by + * the OAuth login flow before it writes to disk). + * @param options.warningLogger if provided, a one-time warning is emitted when a + * deprecated v1 `api_token` is found on disk. Pass the consumer's logger (e.g. + * wrangler's logger singleton) to surface this to the user. + */ +export function readStoredAuthState(options?: { + configOverride?: UserAuthConfig; + warningLogger?: Pick; +}): StoredAuthState { + const { configOverride, warningLogger } = options ?? {}; + + let parsed: UserAuthConfig; + try { + parsed = configOverride ?? readAuthConfigFile(); + } catch { + return {}; + } + + const { oauth_token, refresh_token, expiration_time, scopes, api_token } = + parsed; + + if (oauth_token) { + return { + accessToken: { + value: oauth_token, + // If there is no `expiration_time` field then set it to an old date, to cause it to expire immediately. + expiry: expiration_time ?? "2000-01-01:00:00:00+00:00", + }, + refreshToken: { value: refresh_token ?? "" }, + scopes, + }; + } + + if (api_token) { + if (!hasWarnedAboutDeprecatedV1ApiToken && warningLogger) { + hasWarnedAboutDeprecatedV1ApiToken = true; + warningLogger.warn( + "It looks like you have used Wrangler v1's `config` command to login with an API token\n" + + `from ${configOverride === undefined ? getAuthConfigFilePath() : "in-memory config"}.\n` + + "This is no longer supported in the current version of Wrangler.\n" + + "If you wish to authenticate via an API token then please set the `CLOUDFLARE_API_TOKEN` environment variable." + ); + } + return { deprecatedApiToken: api_token }; + } + + return {}; +} diff --git a/packages/workers-auth/src/test-helpers/index.ts b/packages/workers-auth/src/test-helpers/index.ts new file mode 100644 index 0000000000..2642aa3a10 --- /dev/null +++ b/packages/workers-auth/src/test-helpers/index.ts @@ -0,0 +1,2 @@ +export { mswAccessHandlers } from "./msw-handlers/access"; +export { mswSuccessOauthHandlers } from "./msw-handlers/oauth"; diff --git a/packages/wrangler/src/__tests__/helpers/msw/handlers/access.ts b/packages/workers-auth/src/test-helpers/msw-handlers/access.ts similarity index 95% rename from packages/wrangler/src/__tests__/helpers/msw/handlers/access.ts rename to packages/workers-auth/src/test-helpers/msw-handlers/access.ts index 5f495ea5ea..4837338dd0 100644 --- a/packages/wrangler/src/__tests__/helpers/msw/handlers/access.ts +++ b/packages/workers-auth/src/test-helpers/msw-handlers/access.ts @@ -1,6 +1,6 @@ import { http, HttpResponse } from "msw"; -export default [ +export const mswAccessHandlers = [ http.get("https://access-protected.com/", () => { return HttpResponse.json(null, { status: 302, diff --git a/packages/wrangler/src/__tests__/helpers/msw/handlers/oauth.ts b/packages/workers-auth/src/test-helpers/msw-handlers/oauth.ts similarity index 100% rename from packages/wrangler/src/__tests__/helpers/msw/handlers/oauth.ts rename to packages/workers-auth/src/test-helpers/msw-handlers/oauth.ts diff --git a/packages/workers-auth/src/token-exchange.ts b/packages/workers-auth/src/token-exchange.ts new file mode 100644 index 0000000000..f91c1c8c9a --- /dev/null +++ b/packages/workers-auth/src/token-exchange.ts @@ -0,0 +1,359 @@ +/* Based heavily on code from https://github.com/BitySA/oauth2-auth-code-pkce + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import assert from "node:assert"; +import { fetch } from "undici"; +import { domainUsesAccess, getCloudflareAccessHeaders } from "./access"; +import { + getAuthDomainFromEnv, + getAuthUrlFromEnv, + getClientIdFromEnv, + getTokenUrlFromEnv, +} from "./env-vars"; +import { + ErrorInvalidReturnedStateParam, + ErrorUnknown, + toErrorClass, +} from "./errors"; +import { OAUTH_CALLBACK_URL } from "./generate-auth-url"; +import { generatePKCECodes, RECOMMENDED_STATE_LENGTH } from "./pkce"; +import { readStoredAuthState, type OAuthFlowState } from "./state"; +import type { OAuthFlowContext } from "./context"; +import type { generateAuthUrl as defaultGenerateAuthUrl } from "./generate-auth-url"; +import type { generateRandomState as defaultGenerateRandomState } from "./generate-random-state"; +import type { AccessToken, RefreshToken } from "./state"; +import type { ParsedUrlQuery } from "node:querystring"; +import type { Response } from "undici"; + +export interface AccessContext { + token?: AccessToken; + scopes?: string[]; + refreshToken?: RefreshToken; +} + +type TokenResponse = + | { + access_token: string; + expires_in: number; + refresh_token: string; + scope: string; + } + | { + error: string; + }; + +/** + * If there is an error, it will be passed back as a rejected Promise. + * If there is no code, the user should be redirected via + * [fetchAuthorizationCode]. + */ +export function isReturningFromAuthServer( + query: ParsedUrlQuery, + state: OAuthFlowState, + logger: OAuthFlowContext["logger"] +): boolean { + if (query.error) { + if (Array.isArray(query.error)) { + throw toErrorClass(query.error[0]); + } + throw toErrorClass(query.error); + } + + const code = query.code; + if (!code) { + return false; + } + + const stateQueryParam = query.state; + if (stateQueryParam !== state.stateQueryParam) { + logger.warn( + "Received query string parameter doesn't match the one sent! Possible malicious activity somewhere." + ); + throw new ErrorInvalidReturnedStateParam("", { + telemetryMessage: "user oauth invalid returned state", + }); + } + assert(!Array.isArray(code)); + state.authorizationCode = code; + state.hasAuthCodeBeenExchangedForAccessToken = false; + return true; +} + +/** + * Build the OAuth authorize URL and seed the transient flow state with the + * matching PKCE / CSRF values. + */ +export async function getAuthURL( + scopes: string[], + clientId: string, + state: OAuthFlowState, + generators: { + generateAuthUrl: typeof defaultGenerateAuthUrl; + generateRandomState: typeof defaultGenerateRandomState; + } +): Promise { + const { codeChallenge, codeVerifier } = await generatePKCECodes(); + const stateQueryParam = generators.generateRandomState( + RECOMMENDED_STATE_LENGTH + ); + + Object.assign(state, { + codeChallenge, + codeVerifier, + stateQueryParam, + }); + + return generators.generateAuthUrl({ + authUrl: getAuthUrlFromEnv(), + clientId, + scopes, + stateQueryParam, + codeChallenge, + }); +} + +/** + * Refresh an access token from the remote service. + */ +export async function exchangeRefreshTokenForAccessToken( + logger: OAuthFlowContext["logger"], + isNonInteractiveOrCI: OAuthFlowContext["isNonInteractiveOrCI"] +): Promise { + // Read the refresh token fresh from disk on every call so we always pick up + // the latest rotation written by a sibling Wrangler process. + const storedRefreshToken = readStoredAuthState({ + warningLogger: logger, + }).refreshToken; + if (!storedRefreshToken) { + logger.warn("No refresh token is present."); + } + + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: storedRefreshToken?.value ?? "", + client_id: getClientIdFromEnv(), + }); + + const response = await fetchAuthToken(params, logger, isNonInteractiveOrCI); + + if (response.status >= 400) { + let tokenExchangeResErr = undefined; + + try { + tokenExchangeResErr = await getJSONFromResponse(response, logger); + } catch (e) { + // If it can't parse to JSON ignore the error + logger.error(e); + } + + if (tokenExchangeResErr !== undefined) { + // We will throw the parsed error if it parsed correctly, otherwise we throw an unknown error. + throw typeof tokenExchangeResErr === "string" + ? new Error(tokenExchangeResErr) + : tokenExchangeResErr; + } else { + throw new ErrorUnknown( + "Failed to parse Error from exchangeRefreshTokenForAccessToken", + { telemetryMessage: "user oauth refresh token exchange parse error" } + ); + } + } else { + try { + const json = (await getJSONFromResponse( + response, + logger + )) as TokenResponse; + if ("error" in json) { + throw json.error; + } + + const { access_token, expires_in, refresh_token, scope } = json; + + const accessToken: AccessToken = { + value: access_token, + expiry: new Date(Date.now() + expires_in * 1000).toISOString(), + }; + + // Multiple scopes are passed and delimited by spaces, + // despite using the singular name "scope". + const scopes: string[] = scope ? scope.split(" ") : []; + + // The caller (refreshToken) persists this via writeAuthConfigFile. + // No need to mirror the values into any module-level cache. + // + // The OAuth server is allowed to omit `refresh_token` from a successful + // refresh response, in which case the previously issued refresh token + // remains valid (RFC 6749 §6). Preserve the stored value so we don't + // wipe a still-valid refresh token from disk. + const accessContext: AccessContext = { + token: accessToken, + scopes, + refreshToken: refresh_token + ? { value: refresh_token } + : storedRefreshToken, + }; + return accessContext; + } catch (error) { + if (typeof error === "string") { + throw toErrorClass(error); + } else { + throw error; + } + } + } +} + +/** + * Fetch an access token from the remote service. + */ +export async function exchangeAuthCodeForAccessToken( + state: OAuthFlowState, + logger: OAuthFlowContext["logger"], + isNonInteractiveOrCI: OAuthFlowContext["isNonInteractiveOrCI"] +): Promise { + const { authorizationCode, codeVerifier = "" } = state; + + if (!codeVerifier) { + logger.warn("No code verifier is being sent."); + } else if (!authorizationCode) { + logger.warn("No authorization grant code is being passed."); + } + + const params = new URLSearchParams({ + grant_type: `authorization_code`, + code: authorizationCode ?? "", + redirect_uri: OAUTH_CALLBACK_URL, + client_id: getClientIdFromEnv(), + code_verifier: codeVerifier, + }); + + const response = await fetchAuthToken(params, logger, isNonInteractiveOrCI); + if (!response.ok) { + const { error } = (await getJSONFromResponse(response, logger)) as { + error: string; + }; + // .catch((_) => ({ error: "invalid_json" })); + if (error === "invalid_grant") { + logger.log("Expired! Auth code or refresh token needs to be renewed."); + // alert("Redirecting to auth server to obtain a new auth grant code."); + // TODO: return refreshAuthCodeOrRefreshToken(); + } + throw toErrorClass(error); + } + const json = (await getJSONFromResponse(response, logger)) as TokenResponse; + if ("error" in json) { + throw new Error(json.error); + } + const { access_token, expires_in, refresh_token, scope } = json; + state.hasAuthCodeBeenExchangedForAccessToken = true; + + const expiryDate = new Date(Date.now() + expires_in * 1000); + const accessToken: AccessToken = { + value: access_token, + expiry: expiryDate.toISOString(), + }; + + // Multiple scopes are passed and delimited by spaces, + // despite using the singular name "scope". + const scopes: string[] = scope ? scope.split(" ") : []; + + // The caller (login) persists this via writeAuthConfigFile. + // No need to mirror the values into any module-level cache. + const accessContext: AccessContext = { + token: accessToken, + scopes, + refreshToken: refresh_token ? { value: refresh_token } : undefined, + }; + return accessContext; +} + +/** + * Make a request to the Cloudflare OAuth endpoint to get a token. + * + * Note that the `body` of the POST request is form-urlencoded so + * can be represented by a URLSearchParams object. + */ +export async function fetchAuthToken( + body: URLSearchParams, + logger: OAuthFlowContext["logger"], + isNonInteractiveOrCI: OAuthFlowContext["isNonInteractiveOrCI"] +): Promise { + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + }; + // Only log the grant_type — never serialize the full body, which contains + // the refresh token / auth code / code verifier. If debug logging is + // enabled and logs are persisted, the full body would leak credentials. + logger.debug( + "fetching auth token", + `grant_type=${body.get("grant_type") ?? ""}` + ); + if (await domainUsesAccess(getAuthDomainFromEnv(), logger)) { + logger.debug( + "Using Cloudflare Access to get an access token for the auth request" + ); + // We are trying to access a domain behind Access so we need auth headers. + const accessHeaders = await getCloudflareAccessHeaders({ + logger, + isNonInteractiveOrCI, + }); + Object.assign(headers, accessHeaders); + } + logger.debug("Fetching auth token from", getTokenUrlFromEnv()); + try { + const response = await fetch(getTokenUrlFromEnv(), { + method: "POST", + body: body.toString(), + headers, + }); + if (!response.ok) { + logger.error( + "Failed to fetch auth token:", + response.status, + response.statusText + ); + } + return response; + } catch (e) { + logger.error("Failed to fetch auth token:", e); + throw e; + } +} + +async function getJSONFromResponse( + response: Response, + logger: OAuthFlowContext["logger"] +) { + const text = await response.text(); + try { + return JSON.parse(text); + } catch (e) { + // Sometime we get an error response where the body is HTML + if (text.match(//)) { + logger.error( + "The body of the response was HTML rather than JSON. Check the debug logs to see the full body of the response." + ); + if (text.match(/challenge-platform/)) { + logger.error( + `It looks like you might have hit a bot challenge page. This may be transient but if not, please contact Cloudflare to find out what can be done. When you contact Cloudflare, please provide your Ray ID: ${response.headers.get("cf-ray")}` + ); + } + } + logger.debug("Full body of response\n\n", text); + throw new Error( + `Invalid JSON in response: status: ${response.status} ${response.statusText}`, + { cause: e } + ); + } +} diff --git a/packages/wrangler/src/__tests__/access.test.ts b/packages/workers-auth/tests/access.test.ts similarity index 63% rename from packages/wrangler/src/__tests__/access.test.ts rename to packages/workers-auth/tests/access.test.ts index c2eec32944..69576aa5fb 100644 --- a/packages/wrangler/src/__tests__/access.test.ts +++ b/packages/workers-auth/tests/access.test.ts @@ -1,20 +1,57 @@ -import ci from "ci-info"; -import { beforeEach, describe, it, vi } from "vitest"; +import { setupServer } from "msw/node"; +import { + afterAll, + afterEach, + beforeAll, + 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"; +} from "../src/access"; +import { mswAccessHandlers } from "../src/test-helpers/msw-handlers/access"; + +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn((binary: string) => { + if (binary === "cloudflared") { + return { error: true }; + } + return { + error: null, + stdout: Buffer.from(""), + stderr: Buffer.from(""), + status: 0, + }; + }), +})); + +const msw = setupServer(); + +beforeAll(() => msw.listen({ onUnhandledRequest: "error" })); +afterEach(() => { + msw.restoreHandlers(); + msw.resetHandlers(); +}); +afterAll(() => msw.close()); -describe("access", () => { - const { setIsTTY } = useMockIsTTY(); - const std = mockConsoleMethods(); +const silentLogger = { + debug: () => {}, + info: () => {}, + log: () => {}, + warn: vi.fn(), + error: () => {}, +}; +const isNonInteractiveOrCI = () => true; + +describe("access", () => { beforeEach(() => { clearAccessCaches(); + silentLogger.warn = vi.fn(); msw.use(...mswAccessHandlers); }); @@ -22,8 +59,12 @@ describe("access", () => { 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(); + expect( + await domainUsesAccess("access-protected.com", silentLogger) + ).toBeTruthy(); + expect( + await domainUsesAccess("not-access-protected.com", silentLogger) + ).toBeFalsy(); }); it("should return false when the domain responds with a 403 (service-auth-only Access app)", async ({ @@ -36,7 +77,7 @@ describe("access", () => { // `getAccessHeaders` must check the env vars before calling // `domainUsesAccess`. expect( - await domainUsesAccess("access-service-auth-only.com") + await domainUsesAccess("access-service-auth-only.com", silentLogger) ).toBeFalsy(); }); }); @@ -45,7 +86,12 @@ describe("access", () => { it("should return empty headers for non-access-protected domains", async ({ expect, }) => { - expect(await getAccessHeaders("not-access-protected.com")).toEqual({}); + expect( + await getAccessHeaders("not-access-protected.com", { + logger: silentLogger, + isNonInteractiveOrCI, + }) + ).toEqual({}); }); describe("service token authentication", () => { @@ -55,13 +101,16 @@ describe("access", () => { 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"); + const headers = await getAccessHeaders("access-protected.com", { + logger: silentLogger, + isNonInteractiveOrCI, + }); 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(`""`); + expect(silentLogger.warn).not.toHaveBeenCalled(); }); it("should return service token headers for a service-auth-only domain (403 response)", async ({ @@ -71,37 +120,44 @@ describe("access", () => { // only allow Service Auth tokens, the domain responds with a // hard 403 instead of redirecting to cloudflareaccess.com. // `domainUsesAccess` returns false in this case, so the env var - // check must happen first - otherwise Wrangler would return - // empty headers and the request would fail with a 403. + // check must happen first - otherwise empty headers would be + // returned and the request would fail with a 403. vi.stubEnv("CLOUDFLARE_ACCESS_CLIENT_ID", "test-client-id.access"); vi.stubEnv("CLOUDFLARE_ACCESS_CLIENT_SECRET", "test-client-secret"); - const headers = await getAccessHeaders("access-service-auth-only.com"); + const headers = await getAccessHeaders("access-service-auth-only.com", { + logger: silentLogger, + isNonInteractiveOrCI, + }); expect(headers).toEqual({ "CF-Access-Client-Id": "test-client-id.access", "CF-Access-Client-Secret": "test-client-secret", }); - expect(std.warn).toMatchInlineSnapshot(`""`); + expect(silentLogger.warn).not.toHaveBeenCalled(); }); 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") + getAccessHeaders("access-protected.com", { + logger: silentLogger, + isNonInteractiveOrCI: () => true, + }) ).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(silentLogger.warn).toHaveBeenCalledWith( + expect.stringContaining( + "Both CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET must be set" + ) ); - expect(std.warn).toContain( - "Only CLOUDFLARE_ACCESS_CLIENT_ID was found" + expect(silentLogger.warn).toHaveBeenCalledWith( + expect.stringContaining("Only CLOUDFLARE_ACCESS_CLIENT_ID was found") ); }); @@ -109,20 +165,26 @@ See https://developers.cloudflare.com/cloudflare-one/access-controls/service-cre expect, }) => { vi.stubEnv("CLOUDFLARE_ACCESS_CLIENT_SECRET", "test-client-secret"); - setIsTTY(false); await expect( - getAccessHeaders("access-protected.com") + getAccessHeaders("access-protected.com", { + logger: silentLogger, + isNonInteractiveOrCI: () => true, + }) ).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(silentLogger.warn).toHaveBeenCalledWith( + expect.stringContaining( + "Both CLOUDFLARE_ACCESS_CLIENT_ID and CLOUDFLARE_ACCESS_CLIENT_SECRET must be set" + ) ); - expect(std.warn).toContain( - "Only CLOUDFLARE_ACCESS_CLIENT_SECRET was found" + expect(silentLogger.warn).toHaveBeenCalledWith( + expect.stringContaining( + "Only CLOUDFLARE_ACCESS_CLIENT_SECRET was found" + ) ); }); }); @@ -131,25 +193,11 @@ See https://developers.cloudflare.com/cloudflare-one/access-controls/service-cre 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") + getAccessHeaders("access-protected.com", { + logger: silentLogger, + isNonInteractiveOrCI, + }) ).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. @@ -162,11 +210,11 @@ See https://developers.cloudflare.com/cloudflare-one/access-controls/service-cre 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") + getAccessHeaders("access-protected.com", { + logger: silentLogger, + isNonInteractiveOrCI: () => false, + }) ).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/workers-auth/tests/tsconfig.json b/packages/workers-auth/tests/tsconfig.json new file mode 100644 index 0000000000..b84eed21b9 --- /dev/null +++ b/packages/workers-auth/tests/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "module": "esnext", + "types": ["node"], + "noEmit": true + }, + "include": ["**/*.ts", "../src/**/*.ts"] +} diff --git a/packages/workers-auth/tests/vitest.global.ts b/packages/workers-auth/tests/vitest.global.ts new file mode 100644 index 0000000000..b1551c1fae --- /dev/null +++ b/packages/workers-auth/tests/vitest.global.ts @@ -0,0 +1,8 @@ +export function setup(): void { + // Set `LC_ALL` to fix the language as English for the messages thrown by Yargs, + // and to make any uses of datetimes in snapshots consistent. + // This needs to be in a globalSetup script - it won't work in a setupFile script. + // https://github.com/vitest-dev/vitest/issues/1575#issuecomment-1439286286 + process.env.LC_ALL = "C"; + process.env.TZ = "UTC"; +} diff --git a/packages/workers-auth/tests/vitest.setup.ts b/packages/workers-auth/tests/vitest.setup.ts new file mode 100644 index 0000000000..35022fb3f8 --- /dev/null +++ b/packages/workers-auth/tests/vitest.setup.ts @@ -0,0 +1,34 @@ +/* eslint-disable @typescript-eslint/consistent-type-imports -- Setup file uses dynamic imports where typeof requires value imports */ +import { vi } from "vitest"; + +vi.mock("undici", async (importOriginal) => { + return { + ...(await importOriginal()), + /** + * Why do we have this hacky mock? + * + * MSW intercepts requests made via globalThis.fetch but not undici.fetch. + * Since workers-auth imports fetch, Request, and Response from undici, + * we need to replace them with their global equivalents so MSW can + * intercept and properly handle requests. + * + * We use getters so that we always get the up-to-date mocked versions + * that MSW provides. + */ + get fetch() { + return globalThis.fetch; + }, + get FormData() { + return globalThis.FormData; + }, + get Headers() { + return globalThis.Headers; + }, + get Request() { + return globalThis.Request; + }, + get Response() { + return globalThis.Response; + }, + }; +}); diff --git a/packages/workers-auth/tsconfig.json b/packages/workers-auth/tsconfig.json new file mode 100644 index 0000000000..cbbe70640b --- /dev/null +++ b/packages/workers-auth/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@cloudflare/workers-tsconfig/tsconfig.json", + "compilerOptions": { + "module": "esnext", + "types": ["node"], + "tsBuildInfoFile": ".tsbuildinfo" + }, + "include": ["**/*.ts", "**/*.js"], + "exclude": ["dist", "node_modules", "**/__tests__", "**/*.test.ts"] +} diff --git a/packages/workers-auth/tsup.config.ts b/packages/workers-auth/tsup.config.ts new file mode 100644 index 0000000000..515de26dd2 --- /dev/null +++ b/packages/workers-auth/tsup.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "tsup"; + +export default defineConfig(() => [ + { + treeshake: true, + keepNames: true, + entry: ["src/index.ts", "src/test-helpers/index.ts"], + platform: "node", + format: "esm", + dts: true, + outDir: "dist", + tsconfig: "tsconfig.json", + metafile: true, + sourcemap: process.env.SOURCEMAPS !== "false", + define: { + "process.env.NODE_ENV": `'${"production"}'`, + }, + external: ["@cloudflare/*", "vitest", "undici", "msw"], + }, +]); diff --git a/packages/workers-auth/turbo.json b/packages/workers-auth/turbo.json new file mode 100644 index 0000000000..691f611507 --- /dev/null +++ b/packages/workers-auth/turbo.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "inputs": ["$TURBO_DEFAULT$", "!**/tests/**"], + "outputs": ["dist/**"], + "env": ["SOURCEMAPS"], + "passThroughEnv": ["PWD"] + }, + "test:ci": { + "dependsOn": ["build"], + "env": ["LC_ALL", "TZ"] + } + } +} diff --git a/packages/workers-auth/vitest.config.mts b/packages/workers-auth/vitest.config.mts new file mode 100644 index 0000000000..c742459c92 --- /dev/null +++ b/packages/workers-auth/vitest.config.mts @@ -0,0 +1,15 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + testTimeout: 15_000, + pool: "forks", + include: ["**/tests/**/*.test.ts"], + globalSetup: path.resolve(__dirname, "tests/vitest.global.ts"), + setupFiles: [path.resolve(__dirname, "tests/vitest.setup.ts")], + reporters: ["default"], + unstubEnvs: true, + mockReset: true, + }, +}); diff --git a/packages/wrangler/e2e/auth-scopes.test.ts b/packages/wrangler/e2e/auth-scopes.test.ts index 7a6d66304f..e7322ab5c0 100644 --- a/packages/wrangler/e2e/auth-scopes.test.ts +++ b/packages/wrangler/e2e/auth-scopes.test.ts @@ -1,9 +1,9 @@ -import { fetch } from "undici"; -import { describe, it } from "vitest"; import { getAuthUrlFromEnv, getClientIdFromEnv, -} from "../src/user/auth-variables"; +} from "@cloudflare/workers-auth"; +import { fetch } from "undici"; +import { describe, it } from "vitest"; import { generateAuthUrl } from "../src/user/generate-auth-url"; import { DefaultScopeKeys } from "../src/user/user"; diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index c75134791e..06610aced4 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -83,6 +83,7 @@ "@cloudflare/deploy-helpers": "workspace:*", "@cloudflare/pages-shared": "workspace:^", "@cloudflare/types": "6.18.4", + "@cloudflare/workers-auth": "workspace:*", "@cloudflare/workers-shared": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", "@cloudflare/workers-types": "catalog:default", diff --git a/packages/wrangler/src/__tests__/helpers/mock-http-server.ts b/packages/wrangler/src/__tests__/helpers/mock-http-server.ts index 97f1ddceb7..67263832df 100644 --- a/packages/wrangler/src/__tests__/helpers/mock-http-server.ts +++ b/packages/wrangler/src/__tests__/helpers/mock-http-server.ts @@ -19,6 +19,11 @@ export function mockHttpServer() { callback?.(); return this; }, + // The OAuth callback server registers an `error` listener so it + // can surface `EADDRINUSE` cleanly. The mock server never emits + // errors, so these are no-ops. + on: vi.fn().mockReturnThis(), + once: vi.fn().mockReturnThis(), } as unknown as http.Server; }); }); diff --git a/packages/wrangler/src/__tests__/helpers/msw/index.ts b/packages/wrangler/src/__tests__/helpers/msw/index.ts index 133fac627f..b0647042ae 100644 --- a/packages/wrangler/src/__tests__/helpers/msw/index.ts +++ b/packages/wrangler/src/__tests__/helpers/msw/index.ts @@ -1,5 +1,8 @@ +import { + mswAccessHandlers, + mswSuccessOauthHandlers, +} from "@cloudflare/workers-auth/test-helpers"; import { setupServer } from "msw/node"; -import { default as mswAccessHandlers } from "./handlers/access"; import { mswSuccessDeploymentDetails, mswSuccessDeployments, @@ -7,7 +10,6 @@ import { mswSuccessDeploymentScriptMetadata, } from "./handlers/deployments"; import { mswSuccessNamespacesHandlers } from "./handlers/namespaces"; -import { mswSuccessOauthHandlers } from "./handlers/oauth"; import { mswR2handlers } from "./handlers/r2"; import { default as mswSucessScriptHandlers } from "./handlers/script"; import { diff --git a/packages/wrangler/src/user/access.ts b/packages/wrangler/src/user/access.ts index e86647bfa3..54f3383ef3 100644 --- a/packages/wrangler/src/user/access.ts +++ b/packages/wrangler/src/user/access.ts @@ -1,154 +1,27 @@ -import { spawnSync } from "node:child_process"; -import { UserError } from "@cloudflare/workers-utils"; -import { fetch } from "undici"; +// Thin wrapper around `@cloudflare/workers-auth`'s Access helpers that +// injects wrangler's `logger` singleton and `isNonInteractiveOrCI` predicate so +// the historical call sites can keep using a single-argument signature. +import { + clearAccessCaches as packageClearAccessCaches, + domainUsesAccess as packageDomainUsesAccess, + getAccessHeaders as packageGetAccessHeaders, +} from "@cloudflare/workers-auth"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; -import { - getAccessClientIdFromEnv, - getAccessClientSecretFromEnv, -} from "./auth-variables"; - -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(); + packageClearAccessCaches(); } export async function domainUsesAccess(domain: string): Promise { - logger.debug("Checking if domain has Access enabled:", domain); - - if (usesAccessCache.has(domain)) { - logger.debug( - "Using cached Access switch for:", - domain, - usesAccessCache.get(domain) - ); - return usesAccessCache.get(domain); - } - logger.debug("Access switch not cached for:", domain); - try { - const controller = new AbortController(); - const cancel = setTimeout(() => { - controller.abort(); - }, 1000); - - const output = await fetch(`https://${domain}`, { - redirect: "manual", - signal: controller.signal, - }); - clearTimeout(cancel); - const usesAccess = !!( - output.status === 302 && - output.headers.get("location")?.includes("cloudflareaccess.com") - ); - logger.debug("Caching access switch for:", domain); - - usesAccessCache.set(domain, usesAccess); - return usesAccess; - } catch { - usesAccessCache.set(domain, false); - return false; - } + return packageDomainUsesAccess(domain, logger); } -/** - * 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> { - // 1. If Access Service Token credentials are provided, use them directly. - // - // This check intentionally comes before `domainUsesAccess()`, which detects - // Access by looking for a 302 redirect to `cloudflareaccess.com`. When an - // Access application is configured to only allow Service Auth tokens (no - // interactive user authentication), the domain responds with a hard 403 - // instead of redirecting, so `domainUsesAccess()` returns false. If we - // gated the env var check on `domainUsesAccess()` we would never attach - // the service token headers and the request would fail with a 403. - 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.` - ); - } - - if (!(await domainUsesAccess(domain))) { - return {}; - } - logger.debug("Getting Access headers for domain:", domain); - if (headersCache[domain]) { - logger.debug("Using cached Access headers for domain:", domain); - return headersCache[domain]; - } - - // 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/`, - { - telemetryMessage: "user access missing service token non interactive", - } - ); - } - - // 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) { - 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", - { telemetryMessage: "user access missing cloudflared" } - ); - } - const stringOutput = output.stdout.toString(); - logger.debug("cloudflared output:", stringOutput); - const matches = stringOutput.match(/fetched your token:\n\n(.*)/m); - if (matches && matches.length >= 2) { - 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"); + return packageGetAccessHeaders(domain, { + logger, + isNonInteractiveOrCI, + }); } diff --git a/packages/wrangler/src/user/auth-variables.ts b/packages/wrangler/src/user/auth-variables.ts index 6af5a966af..fdf1a8e64f 100644 --- a/packages/wrangler/src/user/auth-variables.ts +++ b/packages/wrangler/src/user/auth-variables.ts @@ -1,9 +1,4 @@ -import { - getCloudflareApiEnvironmentFromEnv, - getEnvironmentVariableFactory, -} from "@cloudflare/workers-utils"; -import { logger } from "../logger"; -import { getAccessHeaders } from "./access"; +import { getEnvironmentVariableFactory } from "@cloudflare/workers-utils"; /** * `CLOUDFLARE_ACCOUNT_ID` overrides the account inferred from the current user. @@ -17,130 +12,24 @@ export const getCloudflareAPITokenFromEnv = getEnvironmentVariableFactory({ variableName: "CLOUDFLARE_API_TOKEN", deprecatedName: "CF_API_TOKEN", }); + export const getCloudflareGlobalAuthKeyFromEnv = getEnvironmentVariableFactory({ variableName: "CLOUDFLARE_API_KEY", deprecatedName: "CF_API_KEY", }); + export const getCloudflareGlobalAuthEmailFromEnv = getEnvironmentVariableFactory({ variableName: "CLOUDFLARE_EMAIL", deprecatedName: "CF_EMAIL", }); -/** - * `WRANGLER_CLIENT_ID` is a UUID that is used to identify Wrangler - * to the Cloudflare APIs. - * - * Normally you should not need to set this explicitly. - * If you want to switch to the staging environment set the - * `WRANGLER_API_ENVIRONMENT=staging` environment variable instead. - */ -export const getClientIdFromEnv = getEnvironmentVariableFactory({ - variableName: "WRANGLER_CLIENT_ID", - defaultValue: () => - getCloudflareApiEnvironmentFromEnv() === "staging" - ? "4b2ea6cc-9421-4761-874b-ce550e0e3def" - : "54d11594-84e4-41aa-b438-e81b8fa78ee7", -}); - -/** - * `WRANGLER_AUTH_DOMAIN` is the URL base domain that is used - * to access OAuth URLs for the Cloudflare APIs. - * - * Normally you should not need to set this explicitly. - * If you want to switch to the staging environment set the - * `WRANGLER_API_ENVIRONMENT=staging` environment variable instead. - */ -export const getAuthDomainFromEnv = getEnvironmentVariableFactory({ - variableName: "WRANGLER_AUTH_DOMAIN", - defaultValue: () => - getCloudflareApiEnvironmentFromEnv() === "staging" - ? "dash.staging.cloudflare.com" - : "dash.cloudflare.com", -}); - -/** - * `WRANGLER_AUTH_URL` is the path that is used to access OAuth - * for the Cloudflare APIs. - * - * Normally you should not need to set this explicitly. - * If you want to switch to the staging environment set the - * `WRANGLER_API_ENVIRONMENT=staging` environment variable instead. - */ -export const getAuthUrlFromEnv = getEnvironmentVariableFactory({ - variableName: "WRANGLER_AUTH_URL", - defaultValue: () => `https://${getAuthDomainFromEnv()}/oauth2/auth`, -}); - -/** - * `WRANGLER_TOKEN_URL` is the path that is used to exchange an OAuth - * token for an API token. - * - * Normally you should not need to set this explicitly. - * If you want to switch to the staging environment set the - * `WRANGLER_API_ENVIRONMENT=staging` environment variable instead. - */ -export const getTokenUrlFromEnv = getEnvironmentVariableFactory({ - variableName: "WRANGLER_TOKEN_URL", - defaultValue: () => `https://${getAuthDomainFromEnv()}/oauth2/token`, -}); - -/** - * `WRANGLER_REVOKE_URL` is the path that is used to exchange an OAuth - * refresh token for a new OAuth token. - * - * Normally you should not need to set this explicitly. - * If you want to switch to the staging environment set the - * `WRANGLER_API_ENVIRONMENT=staging` environment variable instead. - */ -export const getRevokeUrlFromEnv = getEnvironmentVariableFactory({ - variableName: "WRANGLER_REVOKE_URL", - defaultValue: () => `https://${getAuthDomainFromEnv()}/oauth2/revoke`, -}); - export const getWranglerR2SqlAuthToken = getEnvironmentVariableFactory({ variableName: "WRANGLER_R2_SQL_AUTH_TOKEN", }); -/** - * `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 getCloudflareAccessHeaders = async (): Promise< - Record -> => { - const env = getEnvironmentVariableFactory({ - variableName: "WRANGLER_CF_AUTHORIZATION_TOKEN", - })(); - - // 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 { Cookie: `CF_Authorization=${env}` }; - } - - return getAccessHeaders(getAuthDomainFromEnv()); -}; +// OAuth-flow-related env-var getters (`WRANGLER_CLIENT_ID`, `WRANGLER_AUTH_DOMAIN`, +// `WRANGLER_AUTH_URL`, `WRANGLER_TOKEN_URL`, `WRANGLER_REVOKE_URL`, +// `WRANGLER_CF_AUTHORIZATION_TOKEN`, `CLOUDFLARE_ACCESS_CLIENT_ID`, +// `CLOUDFLARE_ACCESS_CLIENT_SECRET`) have moved to `@cloudflare/workers-auth` +// alongside the OAuth flow itself. diff --git a/packages/wrangler/src/user/generate-auth-url.ts b/packages/wrangler/src/user/generate-auth-url.ts index 0f485a880c..98939251af 100644 --- a/packages/wrangler/src/user/generate-auth-url.ts +++ b/packages/wrangler/src/user/generate-auth-url.ts @@ -1,33 +1,7 @@ -interface GenerateAuthUrlProps { - authUrl: string; - clientId: string; - scopes: string[]; - stateQueryParam: string; - codeChallenge: string; -} - -export const OAUTH_CALLBACK_URL = "http://localhost:8976/oauth/callback"; - -/** - * generateAuthUrl was extracted from getAuthURL in user.tsx - * to make it possible to mock the generated URL - */ -export const generateAuthUrl = ({ - authUrl, - clientId, - scopes, - stateQueryParam, - codeChallenge, -}: GenerateAuthUrlProps) => { - return ( - authUrl + - `?response_type=code&` + - `client_id=${encodeURIComponent(clientId)}&` + - `redirect_uri=${encodeURIComponent(OAUTH_CALLBACK_URL)}&` + - // we add offline_access manually for every request - `scope=${encodeURIComponent([...scopes, "offline_access"].join(" "))}&` + - `state=${stateQueryParam}&` + - `code_challenge=${encodeURIComponent(codeChallenge)}&` + - `code_challenge_method=S256` - ); -}; +// Re-export shim. The real implementation lives in `@cloudflare/workers-auth`. +// +// This file exists so wrangler's tests can continue to `vi.mock("../user/generate-auth-url", ...)` +// to produce deterministic OAuth URLs for snapshot testing. The mocked exports +// are imported by `./user.ts` and injected into the OAuth flow context, +// where the workers-auth package uses them internally. +export { generateAuthUrl, OAUTH_CALLBACK_URL } from "@cloudflare/workers-auth"; diff --git a/packages/wrangler/src/user/generate-random-state.ts b/packages/wrangler/src/user/generate-random-state.ts index 71a2307658..e330cdbfce 100644 --- a/packages/wrangler/src/user/generate-random-state.ts +++ b/packages/wrangler/src/user/generate-random-state.ts @@ -1,15 +1,7 @@ -import { webcrypto as crypto } from "node:crypto"; -import { PKCE_CHARSET } from "../user"; - -/** - * Generates random state to be passed for anti-csrf. - * extracted from user.tsx to make it possible to - * mock the generated URL - */ -export function generateRandomState(lengthOfState: number): string { - const output = new Uint32Array(lengthOfState); - crypto.getRandomValues(output); - return Array.from(output) - .map((num: number) => PKCE_CHARSET[num % PKCE_CHARSET.length]) - .join(""); -} +// Re-export shim. The real implementation lives in `@cloudflare/workers-auth`. +// +// This file exists so wrangler's tests can continue to `vi.mock("../user/generate-random-state", ...)` +// to produce deterministic CSRF state values for snapshot testing. The +// mocked export is imported by `./user.ts` and injected into the OAuth flow +// context, where the workers-auth package uses it internally. +export { generateRandomState } from "@cloudflare/workers-auth"; diff --git a/packages/wrangler/src/user/user.ts b/packages/wrangler/src/user/user.ts index c7836d0d31..6f1924c173 100644 --- a/packages/wrangler/src/user/user.ts +++ b/packages/wrangler/src/user/user.ts @@ -1,262 +1,63 @@ -/* Based heavily on code from https://github.com/BitySA/oauth2-auth-code-pkce */ - -/* - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ +// The OAuth-2.0-with-PKCE flow (login / logout / refresh / token persistence / +// callback server / Cloudflare Access detection) previously lived in this file. +// +// What remains here: +// - Cloudflare credential resolution from environment variables +// - The OAuth scope catalog (Cloudflare-specific; passed into the OAuth flow +// as a generic string[]) +// - Cloudflare account selection (resolves to an `account_id` from config, +// env, cache, or interactive `select` prompt) +// - `requireAuth` / `requireApiToken` — the high-level entry points used by +// wrangler's commands import assert from "node:assert"; -import { webcrypto as crypto } from "node:crypto"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import http from "node:http"; -import path from "node:path"; -import url from "node:url"; -import { TextEncoder } from "node:util"; -import { - configFileName, - getCloudflareApiEnvironmentFromEnv, - getCloudflareComplianceRegion, - getGlobalWranglerConfigPath, - parseTOML, - readFileSync, - UserError, -} from "@cloudflare/workers-utils"; +import { readStoredAuthState } from "@cloudflare/workers-auth"; +import { createOAuthFlow } from "@cloudflare/workers-auth"; +import { configFileName, UserError } from "@cloudflare/workers-utils"; import ci from "ci-info"; -import TOML from "smol-toml"; -import dedent from "ts-dedent"; -import { fetch } from "undici"; -import { - getConfigCache, - purgeConfigCaches, - saveToConfigCache, -} from "../config-cache"; +import { getConfigCache, saveToConfigCache } from "../config-cache"; +import { purgeConfigCaches } from "../config-cache"; import { NoDefaultValueProvided, select } from "../dialogs"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import openInBrowser from "../open-in-browser"; -import { domainUsesAccess } from "./access"; import { - getAuthDomainFromEnv, - getAuthUrlFromEnv, - getClientIdFromEnv, - getCloudflareAccessHeaders, getCloudflareAccountIdFromEnv, getCloudflareAPITokenFromEnv, getCloudflareGlobalAuthEmailFromEnv, getCloudflareGlobalAuthKeyFromEnv, - getRevokeUrlFromEnv, - getTokenUrlFromEnv, } from "./auth-variables"; import { fetchAllAccounts } from "./fetch-accounts"; -import { generateAuthUrl, OAUTH_CALLBACK_URL } from "./generate-auth-url"; +import { generateAuthUrl } from "./generate-auth-url"; import { generateRandomState } from "./generate-random-state"; import type { Account } from "./shared"; +import type { LoginProps } from "@cloudflare/workers-auth"; import type { ApiCredentials, ComplianceConfig, } from "@cloudflare/workers-utils"; -import type { ParsedUrlQuery } from "node:querystring"; -import type { Response } from "undici"; + +/** + * The single wrangler-wide OAuth flow instance. + * + * Wires the OAuth-flow primitives in `@cloudflare/workers-auth` to wrangler's + * logger, browser opener, interactivity detector, and config cache. + * + * The `generateAuthUrl` and `generateRandomState` overrides come from + * wrangler's local re-export shims so that the existing `vi.mock(...)` calls + * in `vitest.setup.ts` (which produce deterministic snapshot URLs) continue to + * apply — the mocked versions are injected via the context here and used + * internally by `@cloudflare/workers-auth`. + */ +const oauthFlow = createOAuthFlow({ + logger, + isNonInteractiveOrCI, + openInBrowser, + hasEnvCredentials: () => getAuthFromEnv() !== undefined, + purgeOnLoginOrLogout: purgeConfigCaches, + generateAuthUrl, + generateRandomState, +}); /** * Try to read API credentials from environment variables. @@ -281,68 +82,9 @@ export function getAuthFromEnv(): ApiCredentials | undefined { } } -/** - * An implementation of rfc6749#section-4.1 and rfc7636. - */ - -interface PKCECodes { - codeChallenge: string; - codeVerifier: string; -} - -/** - * Transient state that is shared across the steps of a single OAuth login flow - * within one Wrangler command. This state is not file-backed; it lives only for - * the duration of an interactive login. - */ -interface OAuthFlowState { - authorizationCode?: string; - codeChallenge?: string; - codeVerifier?: string; - hasAuthCodeBeenExchangedForAccessToken?: boolean; - stateQueryParam?: string; -} - -/** - * The auth state that is stored on disk in the user auth config file (TOML). - * Read on demand by {@link readStoredAuthState} — never cached at module scope - * so that environment variables loaded after import (e.g. from `.env`) take - * priority correctly. - */ -interface StoredAuthState { - accessToken?: AccessToken; - refreshToken?: RefreshToken; - scopes?: Scope[]; - /** @deprecated - this field was only provided by the deprecated v1 `wrangler config` command. */ - deprecatedApiToken?: string; -} - -/** - * The path to the config file that holds user authentication data, - * relative to the user's home directory. - */ -const USER_AUTH_CONFIG_PATH = "config"; - -/** - * The data that may be read from the `USER_CONFIG_FILE`. - */ -export interface UserAuthConfig { - oauth_token?: string; - refresh_token?: string; - expiration_time?: string; - scopes?: string[]; - /** @deprecated - this field was only provided by the deprecated v1 `wrangler config` command. */ - api_token?: string; -} - -interface RefreshToken { - value: string; -} - -interface AccessToken { - value: string; - expiry: string; -} +// --------------------------------------------------------------------------- +// Scope catalog +// --------------------------------------------------------------------------- const DefaultScopes = { "account:read": @@ -407,76 +149,41 @@ export function validateScopeKeys( return scopes.every((scope) => scope in DefaultScopes); } -/** - * Module-level state intentionally limited to the transient state shared - * across the steps of one OAuth login flow. The on-disk OAuth tokens are NOT - * cached here — they are read on demand by readStoredAuthState() so that env - * vars loaded after module import (for example from `.env`) take priority - * correctly. The selected-account cache is file-backed (wrangler-account.json) - * and therefore naturally scoped to the current working directory. - */ -const oauthFlowState: OAuthFlowState = {}; - -let hasWarnedAboutDeprecatedV1ApiToken = false; +export function listScopes(message = "💁 Available scopes:"): void { + logger.log(message); + printScopes(DefaultScopeKeys); +} /** - * Read the on-disk auth state. This is called on demand from every site that - * needs the stored OAuth tokens or the deprecated v1 `api_token`, rather than - * being cached at module scope, so that environment-based credentials loaded - * after module import are honoured. - * - * @return an empty object when no auth config file exists or the file cannot - * be parsed — the caller treats this as "not logged in via local OAuth". - * - * @param config The optional `config` argument lets callers seed the state from an - * in-memory config (used by the OAuth login flow before it writes to disk). + * Get the scopes granted to the current OAuth token. Returns undefined when + * the user is not logged in via OAuth (e.g. env-based auth). */ -function readStoredAuthState(config?: UserAuthConfig): StoredAuthState { - let parsed: UserAuthConfig; - try { - parsed = config ?? readAuthConfigFile(); - } catch { - return {}; - } - - const { oauth_token, refresh_token, expiration_time, scopes, api_token } = - parsed; - - if (oauth_token) { - return { - accessToken: { - value: oauth_token, - // If there is no `expiration_time` field then set it to an old date, to cause it to expire immediately. - expiry: expiration_time ?? "2000-01-01:00:00:00+00:00", - }, - refreshToken: { value: refresh_token ?? "" }, - scopes: scopes as Scope[] | undefined, - }; - } +export function getScopes(): Scope[] | undefined { + return readStoredAuthState({ warningLogger: logger }).scopes as + | Scope[] + | undefined; +} - if (api_token) { - if (!hasWarnedAboutDeprecatedV1ApiToken) { - hasWarnedAboutDeprecatedV1ApiToken = true; - logger.warn( - "It looks like you have used Wrangler v1's `config` command to login with an API token\n" + - `from ${config === undefined ? getAuthConfigFilePath() : "in-memory config"}.\n` + - "This is no longer supported in the current version of Wrangler.\n" + - "If you wish to authenticate via an API token then please set the `CLOUDFLARE_API_TOKEN` environment variable." - ); - } - return { deprecatedApiToken: api_token }; - } +export function printScopes(scopes: Scope[]) { + const data = scopes.map((scope: Scope) => ({ + Scope: scope, + Description: DefaultScopes[scope], + })); - return {}; + logger.table(data); } +// --------------------------------------------------------------------------- +// Credential resolution (combines env + stored OAuth token) +// --------------------------------------------------------------------------- + export function getAPIToken(): ApiCredentials | undefined { const envAuth = getAuthFromEnv(); if (envAuth) { return envAuth; } - const stored = readStoredAuthState(); + const stored = readStoredAuthState({ warningLogger: logger }); if (stored.deprecatedApiToken) { return { apiToken: stored.deprecatedApiToken }; } @@ -487,817 +194,85 @@ export function getAPIToken(): ApiCredentials | undefined { return undefined; } -interface AccessContext { - token?: AccessToken; - scopes?: Scope[]; - refreshToken?: RefreshToken; -} - -/** - * A list of OAuth2AuthCodePKCE errors. - */ -// To "namespace" all errors. -class ErrorOAuth2 extends UserError { - toString(): string { - return "ErrorOAuth2"; - } -} - -// Unclassified Oauth errors -class ErrorUnknown extends UserError { - toString(): string { - return "ErrorUnknown"; - } -} - -// Some generic, internal errors that can happen. -class ErrorNoAuthCode extends ErrorOAuth2 { - toString(): string { - return "ErrorNoAuthCode"; - } -} -class ErrorInvalidReturnedStateParam extends ErrorOAuth2 { - toString(): string { - return "ErrorInvalidReturnedStateParam"; - } -} -class ErrorInvalidJson extends ErrorOAuth2 { - toString(): string { - return "ErrorInvalidJson"; - } -} - -// Errors that occur across many endpoints -class ErrorInvalidScope extends ErrorOAuth2 { - toString(): string { - return "ErrorInvalidScope"; - } -} -class ErrorInvalidRequest extends ErrorOAuth2 { - toString(): string { - return "ErrorInvalidRequest"; - } -} -class ErrorInvalidToken extends ErrorOAuth2 { - toString(): string { - return "ErrorInvalidToken"; - } -} - /** - * Possible authorization grant errors given by the redirection from the - * authorization server. - */ -class ErrorAuthenticationGrant extends ErrorOAuth2 { - toString(): string { - return "ErrorAuthenticationGrant"; - } -} -class ErrorUnauthorizedClient extends ErrorAuthenticationGrant { - toString(): string { - return "ErrorUnauthorizedClient"; - } -} -class ErrorAccessDenied extends ErrorAuthenticationGrant { - toString(): string { - return "ErrorAccessDenied"; - } -} -class ErrorUnsupportedResponseType extends ErrorAuthenticationGrant { - toString(): string { - return "ErrorUnsupportedResponseType"; - } -} -class ErrorServerError extends ErrorAuthenticationGrant { - toString(): string { - return "ErrorServerError"; - } -} -class ErrorTemporarilyUnavailable extends ErrorAuthenticationGrant { - toString(): string { - return "ErrorTemporarilyUnavailable"; - } -} - -/** - * A list of possible access token response errors. - */ -class ErrorAccessTokenResponse extends ErrorOAuth2 { - toString(): string { - return "ErrorAccessTokenResponse"; - } -} -class ErrorInvalidClient extends ErrorAccessTokenResponse { - toString(): string { - return "ErrorInvalidClient"; - } -} -class ErrorInvalidGrant extends ErrorAccessTokenResponse { - toString(): string { - return "ErrorInvalidGrant"; - } -} -class ErrorUnsupportedGrantType extends ErrorAccessTokenResponse { - toString(): string { - return "ErrorUnsupportedGrantType"; - } -} - -/** - * Translate the raw error strings returned from the server into error classes. - */ -function toErrorClass(rawError: string): ErrorOAuth2 | ErrorUnknown { - switch (rawError) { - case "invalid_request": - return new ErrorInvalidRequest(rawError, { - telemetryMessage: "user oauth invalid request", - }); - case "invalid_grant": - return new ErrorInvalidGrant(rawError, { - telemetryMessage: "user oauth invalid grant", - }); - case "unauthorized_client": - return new ErrorUnauthorizedClient(rawError, { - telemetryMessage: "user oauth unauthorized client", - }); - case "access_denied": - return new ErrorAccessDenied(rawError, { - telemetryMessage: "user oauth access denied", - }); - case "unsupported_response_type": - return new ErrorUnsupportedResponseType(rawError, { - telemetryMessage: "user oauth unsupported response type", - }); - case "invalid_scope": - return new ErrorInvalidScope(rawError, { - telemetryMessage: "user oauth invalid scope", - }); - case "server_error": - return new ErrorServerError(rawError, { - telemetryMessage: "user oauth server error", - }); - case "temporarily_unavailable": - return new ErrorTemporarilyUnavailable(rawError, { - telemetryMessage: "user oauth temporarily unavailable", - }); - case "invalid_client": - return new ErrorInvalidClient(rawError, { - telemetryMessage: "user oauth invalid client", - }); - case "unsupported_grant_type": - return new ErrorUnsupportedGrantType(rawError, { - telemetryMessage: "user oauth unsupported grant type", - }); - case "invalid_json": - return new ErrorInvalidJson(rawError, { - telemetryMessage: "user oauth invalid json", - }); - case "invalid_token": - return new ErrorInvalidToken(rawError, { - telemetryMessage: "user oauth invalid token", - }); - default: - return new ErrorUnknown(rawError, { - telemetryMessage: "user oauth unknown error", - }); - } -} - -/** - * The maximum length for a code verifier for the best security we can offer. - * Please note the NOTE section of RFC 7636 § 4.1 - the length must be >= 43, - * but <= 128, **after** base64 url encoding. This means 32 code verifier bytes - * encoded will be 43 bytes, or 96 bytes encoded will be 128 bytes. So 96 bytes - * is the highest valid value that can be used. - */ -const RECOMMENDED_CODE_VERIFIER_LENGTH = 96; - -/** - * A sensible length for the state's length, for anti-csrf. - */ -const RECOMMENDED_STATE_LENGTH = 32; - -/** - * Character set to generate code verifier defined in rfc7636. - */ -export const PKCE_CHARSET = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; - -/** - * OAuth 2.0 client that ONLY supports authorization code flow, with PKCE. - */ - -/** - * If there is an error, it will be passed back as a rejected Promise. - * If there is no code, the user should be redirected via - * [fetchAuthorizationCode]. + * Throw an error if there is no API token available. */ -function isReturningFromAuthServer(query: ParsedUrlQuery): boolean { - if (query.error) { - if (Array.isArray(query.error)) { - throw toErrorClass(query.error[0]); - } - throw toErrorClass(query.error); - } - - const code = query.code; - if (!code) { - return false; - } - - const stateQueryParam = query.state; - if (stateQueryParam !== oauthFlowState.stateQueryParam) { - logger.warn( - "Received query string parameter doesn't match the one sent! Possible malicious activity somewhere." - ); - throw new ErrorInvalidReturnedStateParam("", { - telemetryMessage: "user oauth invalid returned state", +export function requireApiToken(): ApiCredentials { + const credentials = getAPIToken(); + if (!credentials) { + throw new UserError("No API token found.", { + telemetryMessage: "user auth missing api token", }); } - assert(!Array.isArray(code)); - oauthFlowState.authorizationCode = code; - oauthFlowState.hasAuthCodeBeenExchangedForAccessToken = false; - return true; -} - -async function getAuthURL(scopes: string[], clientId: string): Promise { - const { codeChallenge, codeVerifier } = await generatePKCECodes(); - const stateQueryParam = generateRandomState(RECOMMENDED_STATE_LENGTH); - - Object.assign(oauthFlowState, { - codeChallenge, - codeVerifier, - stateQueryParam, - }); - - return generateAuthUrl({ - authUrl: getAuthUrlFromEnv(), - clientId, - scopes, - stateQueryParam, - codeChallenge, - }); -} - -type TokenResponse = - | { - access_token: string; - expires_in: number; - refresh_token: string; - scope: string; - } - | { - error: string; - }; - -/** - * Refresh an access token from the remote service. - */ -async function exchangeRefreshTokenForAccessToken(): Promise { - // Read the refresh token fresh from disk on every call so we always pick up - // the latest rotation written by a sibling Wrangler process. - const storedRefreshToken = readStoredAuthState().refreshToken; - if (!storedRefreshToken) { - logger.warn("No refresh token is present."); - } - - const params = new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: storedRefreshToken?.value ?? "", - client_id: getClientIdFromEnv(), - }); - - const response = await fetchAuthToken(params); - - if (response.status >= 400) { - let tokenExchangeResErr = undefined; - - try { - tokenExchangeResErr = await getJSONFromResponse(response); - } catch (e) { - // If it can't parse to JSON ignore the error - logger.error(e); - } - - if (tokenExchangeResErr !== undefined) { - // We will throw the parsed error if it parsed correctly, otherwise we throw an unknown error. - throw typeof tokenExchangeResErr === "string" - ? new Error(tokenExchangeResErr) - : tokenExchangeResErr; - } else { - throw new ErrorUnknown( - "Failed to parse Error from exchangeRefreshTokenForAccessToken", - { telemetryMessage: "user oauth refresh token exchange parse error" } - ); - } - } else { - try { - const json = (await getJSONFromResponse(response)) as TokenResponse; - if ("error" in json) { - throw json.error; - } - - const { access_token, expires_in, refresh_token, scope } = json; - - const accessToken: AccessToken = { - value: access_token, - expiry: new Date(Date.now() + expires_in * 1000).toISOString(), - }; - - // Multiple scopes are passed and delimited by spaces, - // despite using the singular name "scope". - const scopes: Scope[] = scope ? (scope.split(" ") as Scope[]) : []; - - // The caller (refreshToken) persists this via writeAuthConfigFile. - // No need to mirror the values into any module-level cache. - // - // The OAuth server is allowed to omit `refresh_token` from a successful - // refresh response, in which case the previously issued refresh token - // remains valid (RFC 6749 §6). Preserve the stored value so we don't - // wipe a still-valid refresh token from disk. - const accessContext: AccessContext = { - token: accessToken, - scopes, - refreshToken: refresh_token - ? { value: refresh_token } - : storedRefreshToken, - }; - return accessContext; - } catch (error) { - if (typeof error === "string") { - throw toErrorClass(error); - } else { - throw error; - } - } - } + return credentials; } -/** - * Fetch an access token from the remote service. - */ -async function exchangeAuthCodeForAccessToken(): Promise { - const { authorizationCode, codeVerifier = "" } = oauthFlowState; - - if (!codeVerifier) { - logger.warn("No code verifier is being sent."); - } else if (!authorizationCode) { - logger.warn("No authorization grant code is being passed."); - } - - const params = new URLSearchParams({ - grant_type: `authorization_code`, - code: authorizationCode ?? "", - redirect_uri: OAUTH_CALLBACK_URL, - client_id: getClientIdFromEnv(), - code_verifier: codeVerifier, - }); - - const response = await fetchAuthToken(params); - if (!response.ok) { - const { error } = (await getJSONFromResponse(response)) as { - error: string; - }; - // .catch((_) => ({ error: "invalid_json" })); - if (error === "invalid_grant") { - logger.log("Expired! Auth code or refresh token needs to be renewed."); - // alert("Redirecting to auth server to obtain a new auth grant code."); - // TODO: return refreshAuthCodeOrRefreshToken(); - } - throw toErrorClass(error); - } - const json = (await getJSONFromResponse(response)) as TokenResponse; - if ("error" in json) { - throw new Error(json.error); - } - const { access_token, expires_in, refresh_token, scope } = json; - oauthFlowState.hasAuthCodeBeenExchangedForAccessToken = true; +// --------------------------------------------------------------------------- +// Thin wrappers around the OAuth flow that supply default scopes from the +// wrangler-side catalog. Preserves the historical call signatures. +// --------------------------------------------------------------------------- - const expiryDate = new Date(Date.now() + expires_in * 1000); - const accessToken: AccessToken = { - value: access_token, - expiry: expiryDate.toISOString(), - }; - - // Multiple scopes are passed and delimited by spaces, - // despite using the singular name "scope". - const scopes: Scope[] = scope ? (scope.split(" ") as Scope[]) : []; +type WranglerLoginProps = { + scopes?: Scope[]; + browser?: boolean; + callbackHost?: string; + callbackPort?: number; +}; - // The caller (login) persists this via writeAuthConfigFile. - // No need to mirror the values into any module-level cache. - const accessContext: AccessContext = { - token: accessToken, - scopes, - refreshToken: refresh_token ? { value: refresh_token } : undefined, +function withDefaultScopes( + complianceConfig: ComplianceConfig, + props: WranglerLoginProps | undefined +): LoginProps { + return { + complianceConfig, + scopes: props?.scopes ?? DefaultScopeKeys, + browser: props?.browser ?? true, + callbackHost: props?.callbackHost ?? "localhost", + callbackPort: props?.callbackPort ?? 8976, }; - return accessContext; } -/** - * Implements *base64url-encode* (RFC 4648 § 5) without padding, which is NOT - * the same as regular base64 encoding. - */ -function base64urlEncode(value: string): string { - let base64 = btoa(value); - base64 = base64.replace(/\+/g, "-"); - base64 = base64.replace(/\//g, "_"); - base64 = base64.replace(/=/g, ""); - return base64; -} - -/** - * Generates a code_verifier and code_challenge, as specified in rfc7636. - */ - -async function generatePKCECodes(): Promise { - const output = new Uint32Array(RECOMMENDED_CODE_VERIFIER_LENGTH); - crypto.getRandomValues(output); - const codeVerifier = base64urlEncode( - Array.from(output) - .map((num: number) => PKCE_CHARSET[num % PKCE_CHARSET.length]) - .join("") - ); - const buffer = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(codeVerifier) - ); - const hash = new Uint8Array(buffer); - let binary = ""; - const hashLength = hash.byteLength; - for (let i = 0; i < hashLength; i++) { - binary += String.fromCharCode(hash[i]); - } - const codeChallenge = base64urlEncode(binary); - return { codeChallenge, codeVerifier }; -} - -export function getAuthConfigFilePath() { - const environment = getCloudflareApiEnvironmentFromEnv(); - const filePath = `${USER_AUTH_CONFIG_PATH}/${environment === "production" ? "default.toml" : `${environment}.toml`}`; - - return path.join(getGlobalWranglerConfigPath(), filePath); -} - -/** - * Writes a wrangler config file (auth credentials) to disk. - * - * No in-memory cache to invalidate — auth state is read on demand by - * {@link readStoredAuthState} on every call site that needs it. - */ -export function writeAuthConfigFile(config: UserAuthConfig) { - const configPath = getAuthConfigFilePath(); - - mkdirSync(path.dirname(configPath), { - recursive: true, - }); - writeFileSync(path.join(configPath), TOML.stringify(config), { - encoding: "utf-8", - }); +export async function login( + complianceConfig: ComplianceConfig, + props?: WranglerLoginProps +): Promise { + return oauthFlow.login(withDefaultScopes(complianceConfig, props)); } -export function readAuthConfigFile(): UserAuthConfig { - return parseTOML(readFileSync(getAuthConfigFilePath())) as UserAuthConfig; +export async function logout(): Promise { + return oauthFlow.logout(); } -type LoginProps = { - scopes?: Scope[]; - browser: boolean; - callbackHost: string; - callbackPort: number; -}; - export async function loginOrRefreshIfRequired( complianceConfig: ComplianceConfig, - props?: LoginProps + props?: WranglerLoginProps ): Promise { - // TODO: if there already is a token, then try refreshing - // TODO: ask permission before opening browser - if (!getAPIToken()) { - // Not logged in. - // If we are not interactive, we cannot ask the user to login - return !isNonInteractiveOrCI() && (await login(complianceConfig, props)); - } else if (isRefreshNeeded()) { - // We're logged in, but the refresh token seems to have expired, - // so let's try to refresh it - const didRefresh = await refreshToken(); - if (didRefresh) { - // The token was refreshed, so we're done here - return true; - } else { - // If the refresh token isn't valid, then we ask the user to login again - return !isNonInteractiveOrCI() && (await login(complianceConfig, props)); - } - } else { - return true; - } + return oauthFlow.loginOrRefreshIfRequired( + withDefaultScopes(complianceConfig, props) + ); } -/** - * Get the OAuth token from local state, refreshing it if necessary. - * This only handles OAuth tokens stored locally (from `wrangler login`), - * not API tokens or API key/email from environment variables. - * - * Returns the token string if available, or undefined if not logged in. - */ export async function getOAuthTokenFromLocalState(): Promise< string | undefined > { - // Check if we have an OAuth token - let stored = readStoredAuthState(); - if (!stored.accessToken) { - return undefined; - } - - // If the token is expired, try to refresh it - if (isRefreshNeeded()) { - const didRefresh = await refreshToken(); - if (!didRefresh) { - return undefined; - } - // Re-read after the refresh has persisted the new token to disk. - stored = readStoredAuthState(); - } - - return stored.accessToken?.value; -} - -export async function getOauthToken(options: { - browser: boolean; - scopes: string[]; - clientId: string; - denied: { - url: string; - error: string; - }; - granted: { - url: string; - }; - callbackHost: string; - callbackPort: number; -}): Promise { - const urlToOpen = await getAuthURL(options.scopes, options.clientId); - let server: http.Server; - let loginTimeoutHandle: ReturnType; - const timerPromise = new Promise((_, reject) => { - loginTimeoutHandle = setTimeout(() => { - server.close(); - clearTimeout(loginTimeoutHandle); - reject( - new UserError( - "Timed out waiting for authorization code, please try again.", - { telemetryMessage: "user oauth authorization timeout" } - ) - ); - }, 120000); // wait for 120 seconds for the user to authorize - }); - - const loginPromise = new Promise((resolve, reject) => { - server = http.createServer(async (req, res) => { - function finish(token: null, error: Error): void; - function finish(token: AccessContext): void; - function finish(token: AccessContext | null, error?: Error) { - clearTimeout(loginTimeoutHandle); - server.close((closeErr?: Error) => { - if (error || closeErr) { - reject(error || closeErr); - } else { - assert(token); - resolve(token); - } - }); - } - - assert(req.url, "This request doesn't have a URL"); // This should never happen - const { pathname, query } = url.parse(req.url, true); - if (req.method !== "GET") { - return res.end("OK"); - } - switch (pathname) { - case "/oauth/callback": { - let hasAuthCode = false; - try { - hasAuthCode = isReturningFromAuthServer(query); - } catch (err: unknown) { - if (err instanceof ErrorAccessDenied) { - res.writeHead(307, { - Location: options.denied.url, - }); - res.end(() => { - finish( - null, - new UserError(options.denied.error, { - telemetryMessage: "user oauth consent denied", - }) - ); - }); - - return; - } else { - finish(null, err as Error); - return; - } - } - if (!hasAuthCode) { - // render an error page here - finish( - null, - new ErrorNoAuthCode("", { - telemetryMessage: "user oauth missing auth code", - }) - ); - return; - } else { - const exchange = await exchangeAuthCodeForAccessToken(); - res.writeHead(307, { - Location: options.granted.url, - }); - res.end(() => { - finish(exchange); - }); - - return; - } - } - } - }); - - if (options.callbackHost !== "localhost" || options.callbackPort !== 8976) { - logger.log( - `Temporary login server listening on ${options.callbackHost}:${options.callbackPort}` - ); - logger.log( - "Note that the OAuth login page will always redirect to `localhost:8976`.\n" + - "If you have changed the callback host or port because you are running in a container, then ensure that you have port forwarding set up correctly." - ); - } - server.listen(options.callbackPort, options.callbackHost); - }); - if (options.browser) { - logger.log(`Opening a link in your default browser: ${urlToOpen}`); - await openInBrowser(urlToOpen); - } else { - logger.log(`Visit this link to authenticate: ${urlToOpen}`); - } - - return Promise.race([timerPromise, loginPromise]); -} - -export async function login( - complianceConfig: ComplianceConfig, - props: LoginProps = { - browser: true, - callbackHost: "localhost", - callbackPort: 8976, - } -): Promise { - const authFromEnv = getAuthFromEnv(); - if (authFromEnv) { - // Auth from env overrides any login details, so no point in allowing the user to login. - logger.error( - "You are logged in with an API Token. Unset the CLOUDFLARE_API_TOKEN in the " + - "environment to log in via OAuth." - ); - return false; - } - - const complianceRegion = getCloudflareComplianceRegion(complianceConfig); - if (complianceRegion === "fedramp_high") { - const configurationSource = complianceConfig?.compliance_region - ? "`compliance_region` configuration property" - : "`CLOUDFLARE_API_ENVIRONMENT` environment variable"; - throw new UserError( - dedent` - OAuth login is not supported in the \`${complianceRegion}\` compliance region. - Please use a Cloudflare API token (\`CLOUDFLARE_API_TOKEN\` environment variable) or remove the ${configurationSource}. - `, - { - telemetryMessage: "user login unsupported compliance region", - } - ); - } - - logger.log("Attempting to login via OAuth..."); - - const oauth = await getOauthToken({ - browser: !!props.browser, - scopes: props.scopes ?? DefaultScopeKeys, - clientId: getClientIdFromEnv(), - denied: { - url: "https://welcome.developers.workers.dev/wrangler-oauth-consent-denied", - error: - "Error: Consent denied. You must grant consent to Wrangler in order to login.\n" + - "If you don't want to do this consider passing an API token via the `CLOUDFLARE_API_TOKEN` environment variable", - }, - granted: { - url: "https://welcome.developers.workers.dev/wrangler-oauth-consent-granted", - }, - callbackHost: props.callbackHost, - callbackPort: props.callbackPort, - }); - - writeAuthConfigFile({ - oauth_token: oauth.token?.value ?? "", - expiration_time: oauth.token?.expiry, - refresh_token: oauth.refreshToken?.value, - scopes: oauth.scopes, - }); - - logger.log(`Successfully logged in.`); - - purgeConfigCaches(); - - return true; -} - -/** - * Checks to see if we need to refresh the OAuth token. - * - * Returns `false` when env-based credentials are present: in that case the - * env token is what will be used for API calls, and the stored OAuth state - * is not consulted, so its expiry is irrelevant. - * - * Without this short-circuit, the presence of a stale OAuth token on disk - * could spuriously trigger an OAuth refresh that fails and aborts the command, - * even though a perfectly valid env-based credential is in scope. - */ -function isRefreshNeeded(): boolean { - if (getAuthFromEnv()) { - return false; - } - const { accessToken } = readStoredAuthState(); - return Boolean(accessToken && new Date() >= new Date(accessToken.expiry)); -} - -async function refreshToken(): Promise { - // `exchangeRefreshTokenForAccessToken` reads the refresh token fresh from - // disk on every call, so we always pick up the latest rotation written by a - // sibling Wrangler process. Refresh tokens are single-use, so a long-lived - // process such as `wrangler dev` would otherwise send a stale value and get - // a 401 from the token endpoint. - - try { - const { - token: { value: oauth_token, expiry: expiration_time } = { - value: "", - expiry: "", - }, - refreshToken: { value: refresh_token } = {}, - scopes, - } = await exchangeRefreshTokenForAccessToken(); - writeAuthConfigFile({ - oauth_token, - expiration_time, - refresh_token, - scopes, - }); - return true; - } catch (e) { - logger.debug( - `Token refresh failed: ${e instanceof Error ? e.message : String(e)}` - ); - return false; - } + return oauthFlow.getOAuthTokenFromLocalState(); } -export async function logout(): Promise { - const authFromEnv = getAuthFromEnv(); - if (authFromEnv) { - // Auth from env overrides any login details, so we cannot log out. - logger.log( - "You are logged in with an API Token. Unset the CLOUDFLARE_API_TOKEN in the " + - "environment to log out." - ); - return; - } +// Re-export the auth-config-file pure helpers from the package so the +// historical `from "../user"` import paths keep working. +export { + getAuthConfigFilePath, + readAuthConfigFile, + writeAuthConfigFile, +} from "@cloudflare/workers-auth"; +export type { UserAuthConfig } from "@cloudflare/workers-auth"; +// `PKCE_CHARSET` is re-exported for any external consumers that used to +// import it from this barrel. +export { PKCE_CHARSET } from "@cloudflare/workers-auth"; - const storedRefreshToken = readStoredAuthState().refreshToken; - if (!storedRefreshToken) { - logger.log("Not logged in, exiting..."); - return; - } - - const body = - `client_id=${encodeURIComponent(getClientIdFromEnv())}&` + - `token_type_hint=refresh_token&` + - `token=${encodeURIComponent(storedRefreshToken.value || "")}`; - - const response = await fetch(getRevokeUrlFromEnv(), { - method: "POST", - body, - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - }); - await response.text(); // blank text? would be nice if it was something meaningful - rmSync(getAuthConfigFilePath()); - logger.log(`Successfully logged out.`); -} - -export function listScopes(message = "💁 Available scopes:"): void { - logger.log(message); - printScopes(DefaultScopeKeys); - // TODO: maybe a good idea to show usage here -} +// --------------------------------------------------------------------------- +// Account selection +// --------------------------------------------------------------------------- /** * Returns the active account ID without side effects. @@ -1433,19 +408,6 @@ export async function requireAuth( return accountId; } -/** - * Throw an error if there is no API token available. - */ -export function requireApiToken(): ApiCredentials { - const credentials = getAPIToken(); - if (!credentials) { - throw new UserError("No API token found.", { - telemetryMessage: "user auth missing api token", - }); - } - return credentials; -} - /** * Saves the given account details to the filesystem cache. * @@ -1463,84 +425,3 @@ function saveAccountToCache(account: Account): void { export function getAccountFromCache(): undefined | Account { return getConfigCache<{ account: Account }>("wrangler-account.json").account; } - -/** - * Get the scopes of the following token, will only return scopes - * if the token is an OAuth token. - */ -export function getScopes(): Scope[] | undefined { - return readStoredAuthState().scopes; -} - -export function printScopes(scopes: Scope[]) { - const data = scopes.map((scope: Scope) => ({ - Scope: scope, - Description: DefaultScopes[scope], - })); - - logger.table(data); -} - -/** - * Make a request to the Cloudflare OAuth endpoint to get a token. - * - * Note that the `body` of the POST request is form-urlencoded so - * can be represented by a URLSearchParams object. - */ -async function fetchAuthToken(body: URLSearchParams) { - const headers: Record = { - "Content-Type": "application/x-www-form-urlencoded", - }; - logger.debug("fetching auth token", body.toString()); - if (await domainUsesAccess(getAuthDomainFromEnv())) { - logger.debug( - "Using Cloudflare Access to get an access token for the auth request" - ); - // 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 { - const response = await fetch(getTokenUrlFromEnv(), { - method: "POST", - body: body.toString(), - headers, - }); - if (!response.ok) { - logger.error( - "Failed to fetch auth token:", - response.status, - response.statusText - ); - } - return response; - } catch (e) { - logger.error("Failed to fetch auth token:", e); - throw e; - } -} - -async function getJSONFromResponse(response: Response) { - const text = await response.text(); - try { - return JSON.parse(text); - } catch (e) { - // Sometime we get an error response where the body is HTML - if (text.match(//)) { - logger.error( - "The body of the response was HTML rather than JSON. Check the debug logs to see the full body of the response." - ); - if (text.match(/challenge-platform/)) { - logger.error( - `It looks like you might have hit a bot challenge page. This may be transient but if not, please contact Cloudflare to find out what can be done. When you contact Cloudflare, please provide your Ray ID: ${response.headers.get("cf-ray")}` - ); - } - } - logger.debug("Full body of response\n\n", text); - throw new Error( - `Invalid JSON in response: status: ${response.status} ${response.statusText}`, - { cause: e } - ); - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c8b199612..ff79030ca4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3675,6 +3675,46 @@ importers: specifier: catalog:default version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.12.4(@types/node@22.15.17)(typescript@5.8.3))(vite@8.0.13(@types/node@22.15.17)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) + packages/workers-auth: + dependencies: + '@cloudflare/workers-utils': + specifier: workspace:* + version: link:../workers-utils + undici: + specifier: catalog:default + version: 7.24.8 + devDependencies: + '@cloudflare/workers-tsconfig': + specifier: workspace:* + version: link:../workers-tsconfig + '@types/node': + specifier: 22.15.17 + version: 22.15.17 + '@vitest/ui': + specifier: catalog:default + version: 4.1.0(vitest@4.1.0) + concurrently: + specifier: ^8.2.2 + version: 8.2.2 + msw: + specifier: catalog:default + version: 2.12.4(@types/node@22.15.17)(typescript@5.8.3) + smol-toml: + specifier: catalog:default + version: 1.5.2 + ts-dedent: + specifier: ^2.2.0 + version: 2.2.0 + tsup: + specifier: 8.3.0 + version: 8.3.0(@microsoft/api-extractor@7.52.8(@types/node@22.15.17))(jiti@2.6.1)(postcss@8.5.14)(supports-color@9.2.2)(tsx@4.21.0)(typescript@5.8.3)(yaml@2.8.1) + typescript: + specifier: catalog:default + version: 5.8.3 + vitest: + specifier: catalog:default + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.12.4(@types/node@22.15.17)(typescript@5.8.3))(vite@8.0.13(@types/node@22.15.17)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1)) + packages/workers-editor-shared: dependencies: react-split-pane: @@ -4026,6 +4066,9 @@ importers: '@cloudflare/types': specifier: 6.18.4 version: 6.18.4(react@19.2.4) + '@cloudflare/workers-auth': + specifier: workspace:* + version: link:../workers-auth '@cloudflare/workers-shared': specifier: workspace:* version: link:../workers-shared diff --git a/tools/deployments/__tests__/validate-changesets.test.ts b/tools/deployments/__tests__/validate-changesets.test.ts index b1d96abc0e..94f0029121 100644 --- a/tools/deployments/__tests__/validate-changesets.test.ts +++ b/tools/deployments/__tests__/validate-changesets.test.ts @@ -40,6 +40,7 @@ describe("findPackageNames()", () => { "@cloudflare/unenv-preset", "@cloudflare/vite-plugin", "@cloudflare/vitest-pool-workers", + "@cloudflare/workers-auth", "@cloudflare/workers-editor-shared", "@cloudflare/workers-playground", "@cloudflare/workers-shared", From 703ef0290d21703cd288e18f3c556bab58d524ad Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Tue, 2 Jun 2026 22:20:46 +0100 Subject: [PATCH 2/4] bump the workers-auth package to its current version (#14167) --- packages/workers-auth/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workers-auth/package.json b/packages/workers-auth/package.json index 8ecd694477..8b6b4f33c0 100644 --- a/packages/workers-auth/package.json +++ b/packages/workers-auth/package.json @@ -1,6 +1,6 @@ { "name": "@cloudflare/workers-auth", - "version": "0.0.0", + "version": "0.1.0", "description": "Internal OAuth 2.0 + PKCE flow for Cloudflare CLIs. Not intended for external use — APIs may change without notice.", "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/workers-auth#readme", "bugs": { From 7949f81bd258292a4a0b9c5a339c6c035f27d7ca Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Tue, 2 Jun 2026 22:53:27 +0100 Subject: [PATCH 3/4] Skip stale bundles during dev server reload to avoid redundant restarts (#14151) --- .../skip-stale-bundles-during-reload.md | 7 + .../LocalRuntimeController.test.ts | 63 ++++++ .../MultiworkerRuntimeController.test.ts | 202 ++++++++++++++++++ .../RemoteRuntimeController.test.ts | 44 ++++ .../startDevWorker/LocalRuntimeController.ts | 25 +++ .../MultiworkerRuntimeController.ts | 73 ++++++- .../startDevWorker/RemoteRuntimeController.ts | 5 + 7 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 .changeset/skip-stale-bundles-during-reload.md create mode 100644 packages/wrangler/src/__tests__/api/startDevWorker/MultiworkerRuntimeController.test.ts diff --git a/.changeset/skip-stale-bundles-during-reload.md b/.changeset/skip-stale-bundles-during-reload.md new file mode 100644 index 0000000000..ce95b831a1 --- /dev/null +++ b/.changeset/skip-stale-bundles-during-reload.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Skip stale bundles during dev server reload to avoid redundant restarts + +When rapidly saving a wrangler config file with remote bindings, each save would trigger a full reload cycle (remote connection setup, miniflare restart), causing many sequential "Reloading local server... / Establishing remote connection..." messages (while blocking the user). The runtime controllers now check whether a newer bundle has been queued at each expensive async boundary and bail out early if the current bundle is stale. This ensures that only the latest config change triggers a reload, making `wrangler dev` much more responsive during repeated config edits. diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/LocalRuntimeController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/LocalRuntimeController.test.ts index fb0f440ebc..3ffc7dc885 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/LocalRuntimeController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/LocalRuntimeController.test.ts @@ -485,6 +485,69 @@ describe("LocalRuntimeController", () => { res = await fetch(urlFromParts(event.proxyData.userWorkerUrl)); expect(await res.json()).toEqual({ binding: 5, bundle: 5 }); }); + it("should skip stale bundles and only reload once for rapid updates", async ({ + expect, + }) => { + const bus = new FakeBus(); + const controller = new LocalRuntimeController(bus); + teardown(() => controller.teardown()); + + function update(version: number) { + const config = { + name: "worker", + entrypoint: "NOT_REAL", + bindings: { + VERSION: { type: "json", value: version }, + }, + } satisfies Partial; + const bundle = makeEsbuildBundle(dedent /*javascript*/ ` + export default { + fetch(request, env, ctx) { + return Response.json({ binding: env.VERSION, bundle: ${version} }); + } + } + `); + controller.onBundleStart({ + type: "bundleStart", + config: configDefaults(config), + }); + controller.onBundleComplete({ + type: "bundleComplete", + config: configDefaults(config), + bundle, + }); + } + + // Start worker with initial version + update(1); + await bus.waitFor("reloadComplete"); + + // Record events before rapid updates + const eventsBefore = bus.events.length; + + // Fire many rapid updates — simulates repeated config file saves + update(2); + update(3); + update(4); + update(5); + update(6); + + // Wait for the final reloadComplete + const event = await bus.waitFor("reloadComplete"); + const res = await fetch(urlFromParts(event.proxyData.userWorkerUrl)); + expect(await res.json()).toEqual({ binding: 6, bundle: 6 }); + + // Give any stale bundles time to flush through the mutex + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Count how many reloadComplete events were emitted after our rapid + // updates. Stale bundles should bail out early, so we expect exactly + // one reloadComplete for the final (winning) bundle. + const reloadCompleteEvents = bus.events + .slice(eventsBefore) + .filter((e) => e.type === "reloadComplete"); + expect(reloadCompleteEvents).toHaveLength(1); + }); it("should start Miniflare with configured compatibility settings", async ({ expect, }) => { diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/MultiworkerRuntimeController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/MultiworkerRuntimeController.test.ts new file mode 100644 index 0000000000..85254b7e1e --- /dev/null +++ b/packages/wrangler/src/__tests__/api/startDevWorker/MultiworkerRuntimeController.test.ts @@ -0,0 +1,202 @@ +import { runInTempDir } from "@cloudflare/workers-utils/test-helpers"; +import dedent from "ts-dedent"; +import { fetch } from "undici"; +import { describe, it } from "vitest"; +import { MultiworkerRuntimeController } from "../../../api/startDevWorker/MultiworkerRuntimeController"; +import { urlFromParts } from "../../../api/startDevWorker/utils"; +import { FakeBus } from "../../helpers/fake-bus"; +import { mockConsoleMethods } from "../../helpers/mock-console"; +import { useTeardown } from "../../helpers/teardown"; +import { unusable } from "../../helpers/unusable"; +import type { Bundle, StartDevWorkerOptions } from "../../../api"; + +function makeEsbuildBundle(testBundle: string): Bundle { + return { + type: "esm", + modules: [], + id: 0, + path: "/virtual/index.mjs", + entrypointSource: testBundle, + entry: { + file: "index.mjs", + projectRoot: "/virtual/", + configPath: undefined, + format: "modules", + moduleRoot: "/virtual", + name: undefined, + exports: [], + }, + dependencies: {}, + sourceMapPath: undefined, + sourceMapMetadata: undefined, + }; +} + +function configDefaults( + config: Partial +): StartDevWorkerOptions { + return { + name: "test-worker", + compatibilityDate: "2025-10-10", + complianceRegion: undefined, + entrypoint: "NOT_REAL", + projectRoot: "NOT_REAL", + build: unusable(), + legacy: {}, + dev: { persist: "./persist", remote: false }, + ...config, + }; +} + +describe("MultiworkerRuntimeController", () => { + mockConsoleMethods(); + runInTempDir(); + const teardown = useTeardown(); + + describe("stale bundle bail-out", () => { + it("should not bail out when different workers submit bundles", async ({ + expect, + }) => { + const bus = new FakeBus(); + const controller = new MultiworkerRuntimeController(bus, 2); + teardown(() => controller.teardown()); + + function makeWorkerConfig(name: string, primary: boolean) { + return configDefaults({ + name, + entrypoint: "NOT_REAL", + dev: { + persist: "./persist", + remote: false, + multiworkerPrimary: primary, + }, + }); + } + + function makeWorkerBundle(name: string) { + return makeEsbuildBundle(dedent /*javascript*/ ` + export default { + fetch(request, env, ctx) { + return new Response("hello from ${name}"); + } + } + `); + } + + // Submit bundles for both workers — the key scenario that was + // broken: worker B's onBundleComplete would invalidate worker A's + // in-flight processing because they shared a single counter. + const configA = makeWorkerConfig("worker-a", true); + const configB = makeWorkerConfig("worker-b", false); + + controller.onBundleStart({ type: "bundleStart", config: configA }); + controller.onBundleComplete({ + type: "bundleComplete", + config: configA, + bundle: makeWorkerBundle("worker-a"), + }); + + controller.onBundleStart({ type: "bundleStart", config: configB }); + controller.onBundleComplete({ + type: "bundleComplete", + config: configB, + bundle: makeWorkerBundle("worker-b"), + }); + + // Both workers should have their options stored and Miniflare + // should start — resulting in a reloadComplete event. + const event = await bus.waitFor("reloadComplete"); + const res = await fetch(urlFromParts(event.proxyData.userWorkerUrl)); + expect(await res.text()).toContain("hello from"); + }); + + it("should skip stale bundles for the same worker during rapid updates", async ({ + expect, + }) => { + const bus = new FakeBus(); + const controller = new MultiworkerRuntimeController(bus, 2); + teardown(() => controller.teardown()); + + function makeWorkerConfig( + name: string, + primary: boolean, + version?: number + ) { + return configDefaults({ + name, + entrypoint: "NOT_REAL", + bindings: version + ? { VERSION: { type: "json", value: version } } + : undefined, + dev: { + persist: "./persist", + remote: false, + multiworkerPrimary: primary, + }, + }); + } + + function makeWorkerBundle(name: string, version?: number) { + const body = version + ? `Response.json({ name: "${name}", version: ${version} })` + : `new Response("hello from ${name}")`; + return makeEsbuildBundle(dedent /*javascript*/ ` + export default { + fetch(request, env, ctx) { + return ${body}; + } + } + `); + } + + // Initial setup: both workers + const configA = makeWorkerConfig("worker-a", true, 1); + const configB = makeWorkerConfig("worker-b", false); + + controller.onBundleStart({ type: "bundleStart", config: configA }); + controller.onBundleComplete({ + type: "bundleComplete", + config: configA, + bundle: makeWorkerBundle("worker-a", 1), + }); + controller.onBundleStart({ type: "bundleStart", config: configB }); + controller.onBundleComplete({ + type: "bundleComplete", + config: configB, + bundle: makeWorkerBundle("worker-b"), + }); + + await bus.waitFor("reloadComplete"); + + // Record events before rapid updates + const eventsBefore = bus.events.length; + + // Fire rapid updates for worker-a only — simulates repeated + // config saves for a single worker in a multiworker setup. + for (let v = 2; v <= 6; v++) { + const config = makeWorkerConfig("worker-a", true, v); + controller.onBundleStart({ type: "bundleStart", config }); + controller.onBundleComplete({ + type: "bundleComplete", + config, + bundle: makeWorkerBundle("worker-a", v), + }); + } + + // Wait for the final reloadComplete + const event = await bus.waitFor("reloadComplete"); + const res = await fetch(urlFromParts(event.proxyData.userWorkerUrl)); + const json = (await res.json()) as { name: string; version: number }; + expect(json).toEqual({ name: "worker-a", version: 6 }); + + // Give stale bundles time to flush through the mutex + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Stale bundles should bail out early — only one reloadComplete + const reloadCompleteEvents = bus.events + .slice(eventsBefore) + .filter((e) => e.type === "reloadComplete"); + expect(reloadCompleteEvents).toHaveLength(1); + }); + }); +}); diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts index 3167ee2dec..8499685e2b 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/RemoteRuntimeController.test.ts @@ -154,6 +154,50 @@ describe("RemoteRuntimeController", () => { vi.mocked(getAccessHeaders).mockResolvedValue({}); }); + describe("stale bundle bail-out", () => { + it("should skip stale bundles and only reload once for rapid updates", async ({ + expect, + }) => { + const { controller, bus } = setup(); + const config = makeConfig(); + const bundle = makeBundle(); + + // Initial bundle + controller.onBundleStart({ type: "bundleStart", config }); + controller.onBundleComplete({ type: "bundleComplete", config, bundle }); + await bus.waitFor("reloadComplete"); + + // Record events before rapid updates + const eventsBefore = bus.events.length; + vi.mocked(createWorkerPreview).mockClear(); + + // Fire many rapid updates + for (let i = 0; i < 5; i++) { + controller.onBundleStart({ type: "bundleStart", config }); + controller.onBundleComplete({ + type: "bundleComplete", + config, + bundle, + }); + } + + // Wait for the final reloadComplete + await bus.waitFor("reloadComplete"); + + // Give stale bundles time to flush through the mutex + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Stale bundles should bail out early — only one reloadComplete + const reloadCompleteEvents = bus.events + .slice(eventsBefore) + .filter((e) => e.type === "reloadComplete"); + expect(reloadCompleteEvents).toHaveLength(1); + + // The API should only be called once (for the winning bundle) + expect(createWorkerPreview).toHaveBeenCalledTimes(1); + }); + }); + describe("proactive token refresh", () => { afterEach(() => vi.useRealTimers()); diff --git a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts index dab00b886a..1420341e9b 100644 --- a/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/LocalRuntimeController.ts @@ -268,6 +268,12 @@ export class LocalRuntimeController extends RuntimeController { async #onBundleComplete(data: BundleCompleteEvent, id: number) { try { + // A newer bundle has already been queued — skip this stale one + // before doing any expensive work. + if (id !== this.#currentBundleId) { + return; + } + const configBundle = await convertToConfigBundle(data); if (data.config.dev?.remote !== false) { @@ -293,6 +299,12 @@ export class LocalRuntimeController extends RuntimeController { ); } + // Bail out if a newer bundle arrived while we were setting up + // the remote proxy session. + if (id !== this.#currentBundleId) { + return; + } + // Assemble container options and build if necessary if ( @@ -348,6 +360,12 @@ export class LocalRuntimeController extends RuntimeController { logger.log(chalk.dim("⎔ Container image(s) ready")); } + // Bail out if a newer bundle arrived while we were building + // container images. + if (id !== this.#currentBundleId) { + return; + } + const options = await MF.buildMiniflareOptions( this.#log, configBundle, @@ -362,6 +380,13 @@ export class LocalRuntimeController extends RuntimeController { } ); options.liveReload = false; // TODO: set in buildMiniflareOptions once old code path is removed + + // Bail out if a newer bundle arrived while we were building + // miniflare options — avoid a redundant local server reload. + if (id !== this.#currentBundleId) { + return; + } + if (this.#mf === undefined) { logger.log(chalk.dim("⎔ Starting local server...")); this.#mf = new Miniflare(options); diff --git a/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts index 9837a63d94..b42fa348a4 100644 --- a/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.ts @@ -57,7 +57,16 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { // ****************** #log = MF.buildLog(); - #currentBundleId = 0; + // Per-worker bundle ID counters — keyed by worker name so that bundles + // from different workers don't invalidate each other. Only a newer + // bundle for the *same* worker should cause a stale bail-out. + #currentBundleIds = new Map(); + // Global counter that increments for every onBundleComplete, regardless + // of which worker it belongs to. Used to guard setOptions/reloadComplete + // so that we only apply the merged Miniflare config once all pending + // bundles have been processed — prevents intermediate setOptions calls + // with a mix of stale and fresh worker configs. + #globalBundleId = 0; // This is given as a shared secret to the Proxy and User workers // so that the User Worker can trust aspects of HTTP requests from the Proxy Worker @@ -112,8 +121,23 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { }; } - async #onBundleComplete(data: BundleCompleteEvent, id: number) { + #isStaleBundleFor(workerName: string, id: number): boolean { + return id !== this.#currentBundleIds.get(workerName); + } + + async #onBundleComplete( + data: BundleCompleteEvent, + id: number, + globalId: number + ) { + const workerName = data.config.name; try { + // A newer bundle for this worker has already been queued — skip + // this stale one before doing any expensive work. + if (this.#isStaleBundleFor(workerName, id)) { + return; + } + const configBundle = await convertToConfigBundle(data); if (data.config.dev?.remote !== false) { @@ -135,6 +159,12 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { ); } + // Bail out if a newer bundle for this worker arrived while we + // were setting up the remote proxy session. + if (this.#isStaleBundleFor(workerName, id)) { + return; + } + if ( data.config.containers?.length && this.#currentContainerBuildId !== data.config.dev.containerBuildId @@ -178,6 +208,12 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { logger.log(chalk.dim("⎔ Container image(s) ready")); } + // Bail out if a newer bundle for this worker arrived while we + // were building container images. + if (this.#isStaleBundleFor(workerName, id)) { + return; + } + const options = await MF.buildMiniflareOptions( this.#log, await convertToConfigBundle(data), @@ -197,7 +233,26 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { primary: Boolean(data.config.dev.multiworkerPrimary), }); + // Bail out if a newer bundle for this worker arrived while we + // were building miniflare options — avoid a redundant local + // server reload. + if (this.#isStaleBundleFor(workerName, id)) { + return; + } + if (this.#canStartMiniflare()) { + // Use the global bundle counter to decide whether to apply the + // merged config. When multiple workers fire onBundleComplete in + // quick succession (e.g. container rebuild hotkey), each is + // queued in the mutex. Only the *last* queued handler should + // call setOptions, because it will have all workers' updated + // options in #options. Earlier handlers would merge stale + // options from workers that haven't processed yet, causing + // Miniflare to start with an incorrect intermediate config. + if (globalId !== this.#globalBundleId) { + return; + } + const mergedMfOptions = ensureMatchingSql(this.#mergedMfOptions()); if (this.#mf === undefined) { @@ -217,9 +272,10 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { // so only one update can happen at a time. const userWorkerUrl = await this.#mf.ready; const userWorkerInspectorUrl = await this.#mf.getInspectorURL(); - // If we received a new `bundleComplete` event before we were able to - // dispatch a `reloadComplete` for this bundle, ignore this bundle. - if (id !== this.#currentBundleId) { + // If we received a new `bundleComplete` event for *any* worker + // before we were able to dispatch a `reloadComplete`, ignore + // this bundle — the later handler will apply the full config. + if (globalId !== this.#globalBundleId) { return; } @@ -275,7 +331,10 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { } } onBundleComplete(data: BundleCompleteEvent) { - const id = ++this.#currentBundleId; + const prev = this.#currentBundleIds.get(data.config.name) ?? 0; + const id = prev + 1; + this.#currentBundleIds.set(data.config.name, id); + const globalId = ++this.#globalBundleId; if (data.config.dev?.remote) { this.emitErrorEvent({ @@ -296,7 +355,7 @@ export class MultiworkerRuntimeController extends LocalRuntimeController { bundle: data.bundle, }); - void this.#mutex.runWith(() => this.#onBundleComplete(data, id)); + void this.#mutex.runWith(() => this.#onBundleComplete(data, id, globalId)); } #teardown = async (): Promise => { diff --git a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts index dc88158837..cf4fca50df 100644 --- a/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts +++ b/packages/wrangler/src/api/startDevWorker/RemoteRuntimeController.ts @@ -365,6 +365,11 @@ export class RemoteRuntimeController extends RuntimeController { } async #onBundleComplete({ config, bundle }: BundleCompleteEvent, id: number) { + // A newer bundle has already been queued — skip this stale one. + if (id !== this.#currentBundleId) { + return; + } + logger.log(chalk.dim("⎔ Starting remote preview...")); try { From 8400fb945a781e7a7a78a3614a702ace2d1fbc87 Mon Sep 17 00:00:00 2001 From: Ben <4991309+NuroDev@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:22:22 +0100 Subject: [PATCH 4/4] fix(wrangler): version listing limits (#14165) --- .changeset/late-lemons-report.md | 7 ++ .../__tests__/versions/versions.list.test.ts | 82 ++++++++++++++++++- packages/wrangler/src/versions/list.ts | 11 ++- 3 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 .changeset/late-lemons-report.md diff --git a/.changeset/late-lemons-report.md b/.changeset/late-lemons-report.md new file mode 100644 index 0000000000..47f53d6415 --- /dev/null +++ b/.changeset/late-lemons-report.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Limit `wrangler versions list` to the 10 most recent deployable versions + +The versions API ignores pagination when filtering to deployable versions, so Wrangler now caps the command output client-side. This keeps the command aligned with its help text and avoids overwhelming terminal output for Workers with many versions. diff --git a/packages/wrangler/src/__tests__/versions/versions.list.test.ts b/packages/wrangler/src/__tests__/versions/versions.list.test.ts index 5c31d3e75e..1cf77d123f 100644 --- a/packages/wrangler/src/__tests__/versions/versions.list.test.ts +++ b/packages/wrangler/src/__tests__/versions/versions.list.test.ts @@ -2,13 +2,15 @@ import { runInTempDir, writeWranglerConfig, } from "@cloudflare/workers-utils/test-helpers"; +import { http, HttpResponse } from "msw"; import { beforeEach, describe, test } from "vitest"; import { normalizeOutput } from "../../../e2e/helpers/normalize"; import { collectCLIOutput } from "../helpers/collect-cli-output"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; -import { msw, mswListVersions } from "../helpers/msw"; +import { createFetchResult, msw, mswListVersions } from "../helpers/msw"; import { runWrangler } from "../helpers/run-wrangler"; +import type { ApiVersion } from "../../versions/types"; describe("versions list", () => { mockAccountId(); @@ -19,6 +21,39 @@ describe("versions list", () => { const std = collectCLIOutput(); + function createMockVersion(index: number): ApiVersion { + const versionNumber = index + 1; + const paddedVersionNumber = versionNumber.toString().padStart(8, "0"); + const paddedDay = versionNumber.toString().padStart(2, "0"); + const timestamp = `2021-01-${paddedDay}T00:00:00.000000Z`; + + return { + id: `${paddedVersionNumber}-0000-0000-0000-000000000000`, + number: versionNumber, + metadata: { + author_id: "Picard-Gamma-6-0-7-3", + author_email: "Jean-Luc-Picard@federation.org", + created_on: timestamp, + modified_on: timestamp, + source: "wrangler", + }, + resources: { + bindings: [], + script: { + etag: "etag", + handlers: ["fetch"], + last_deployed_from: "wrangler", + }, + script_runtime: { + compatibility_date: "2021-01-01", + compatibility_flags: [], + limits: { cpu_ms: 50 }, + usage_model: "standard", + }, + }, + }; + } + beforeEach(() => { msw.use(mswListVersions); }); @@ -152,6 +187,51 @@ describe("versions list", () => { expect(std.err).toMatchInlineSnapshot(`""`); }); + + test("prints the 10 most recent versions to stdout as valid json", async ({ + expect, + }) => { + msw.use( + http.get( + "*/accounts/:accountId/workers/scripts/:workerName/versions", + ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("deployable")).toBe("true"); + expect(url.searchParams.has("per_page")).toBe(false); + + return HttpResponse.json( + createFetchResult({ + items: Array.from({ length: 12 }, (_, index) => + createMockVersion(index) + ), + }) + ); + } + ) + ); + + const result = runWrangler("versions list --name test-name --json"); + + await expect(result).resolves.toBeUndefined(); + + const versions = JSON.parse(std.out) as ApiVersion[]; + expect(versions.map((version) => version.id)).toMatchInlineSnapshot(` + [ + "00000003-0000-0000-0000-000000000000", + "00000004-0000-0000-0000-000000000000", + "00000005-0000-0000-0000-000000000000", + "00000006-0000-0000-0000-000000000000", + "00000007-0000-0000-0000-000000000000", + "00000008-0000-0000-0000-000000000000", + "00000009-0000-0000-0000-000000000000", + "00000010-0000-0000-0000-000000000000", + "00000011-0000-0000-0000-000000000000", + "00000012-0000-0000-0000-000000000000", + ] + `); + + expect(std.err).toMatchInlineSnapshot(`""`); + }); }); describe("with wrangler.toml", () => { diff --git a/packages/wrangler/src/versions/list.ts b/packages/wrangler/src/versions/list.ts index 670da0a0e7..1e71918351 100644 --- a/packages/wrangler/src/versions/list.ts +++ b/packages/wrangler/src/versions/list.ts @@ -8,6 +8,7 @@ import { fetchDeployableVersions } from "./api"; import type { ApiVersion, VersionCache } from "./types"; const BLANK_INPUT = "-"; // To be used where optional user-input is displayed and the value is nullish +const VERSION_LIST_LIMIT = 10; export const versionsListCommand = createCommand({ metadata: { @@ -50,11 +51,15 @@ export const versionsListCommand = createCommand({ } const versionCache: VersionCache = new Map(); + + // The versions API ignores pagination when `deployable=true`, so cap the command output client-side. const versions = ( await fetchDeployableVersions(config, accountId, workerName, versionCache) - ).sort((a, b) => - a.metadata.created_on.localeCompare(b.metadata.created_on) - ); + ) + .sort((a, b) => + a.metadata.created_on.localeCompare(b.metadata.created_on) + ) + .slice(-VERSION_LIST_LIMIT); if (args.json) { logRaw(JSON.stringify(versions, null, 2));