From b034cb20db1508335421832a2ee0dd5d97c89655 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 20 Oct 2026 13:22:42 +0100 Subject: [PATCH 1/6] fix(workers-auth): expose client-specific auth guidance --- .changeset/auth-consumer-guidance.md | 7 ++ packages/workers-auth/src/cf/index.ts | 3 + packages/workers-auth/src/context.ts | 2 + .../src/core/authentication-error.ts | 13 ++++ packages/workers-auth/src/core/factory.ts | 9 ++- packages/workers-auth/src/core/types.ts | 3 + packages/workers-auth/src/index.ts | 2 + packages/workers-auth/src/temporary.ts | 11 +-- packages/workers-auth/src/wrangler/index.ts | 2 + packages/workers-auth/tests/cf/index.test.ts | 57 ++++++++++++++++ .../workers-auth/tests/core/factory.test.ts | 68 ++++++++++++++++++- .../tests/temporary-event.test.ts | 16 +++++ 12 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 .changeset/auth-consumer-guidance.md create mode 100644 packages/workers-auth/src/core/authentication-error.ts diff --git a/.changeset/auth-consumer-guidance.md b/.changeset/auth-consumer-guidance.md new file mode 100644 index 00000000000..e82948aa340 --- /dev/null +++ b/.changeset/auth-consumer-guidance.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/workers-auth": patch +--- + +Use each CLI's temporary-account flag in authentication errors + +Keep Wrangler's `--temporary` spelling and use `--temporary-account` for cf. Expose account-lookup authentication failure reasons so consumers can distinguish missing credentials from expired tokens without matching error messages. diff --git a/packages/workers-auth/src/cf/index.ts b/packages/workers-auth/src/cf/index.ts index 8a80aae7014..07cc8670de1 100644 --- a/packages/workers-auth/src/cf/index.ts +++ b/packages/workers-auth/src/cf/index.ts @@ -68,6 +68,7 @@ export type { UserPreferences } from "../core/preferences"; export const CF_CLI: CliDescriptor = { cliName: CF_CLI_NAME, displayName: CF_DISPLAY_NAME, + temporaryAccountFlag: "--temporary-account", commands: { login: "cf auth login", logout: "cf auth logout", @@ -142,3 +143,5 @@ export function setKeyringPreference( ): SetKeyringPreferenceResult { return cfKeyringPreference.setKeyringPreference(enabled, ctx); } + +export { AuthenticationError } from "../core/authentication-error"; diff --git a/packages/workers-auth/src/context.ts b/packages/workers-auth/src/context.ts index 710407b66d1..44276be6229 100644 --- a/packages/workers-auth/src/context.ts +++ b/packages/workers-auth/src/context.ts @@ -13,6 +13,8 @@ export interface OAuthFlowTemporaryContext { storage: TemporaryAccountStorage; /** Consumer name used in temporary-account errors. */ cliDisplayName?: string; + /** Consumer temporary-account flag spelling. Defaults to `--temporary`. */ + temporaryAccountFlag?: string; /** Consumer command used to clear a cached temporary account. */ logoutCommand?: string; /** diff --git a/packages/workers-auth/src/core/authentication-error.ts b/packages/workers-auth/src/core/authentication-error.ts new file mode 100644 index 00000000000..777ce43553a --- /dev/null +++ b/packages/workers-auth/src/core/authentication-error.ts @@ -0,0 +1,13 @@ +import { UserError } from "@cloudflare/workers-utils"; +import type { LoginOrRefreshFailureReason } from "../flow"; + +/** Authentication failed before an API request, with a machine-readable reason. */ +export class AuthenticationError extends UserError { + constructor( + message: string, + public readonly reason: LoginOrRefreshFailureReason, + telemetryMessage: string + ) { + super(message, { telemetryMessage }); + } +} diff --git a/packages/workers-auth/src/core/factory.ts b/packages/workers-auth/src/core/factory.ts index 8bc99333c3f..3b20957fd77 100644 --- a/packages/workers-auth/src/core/factory.ts +++ b/packages/workers-auth/src/core/factory.ts @@ -32,6 +32,7 @@ import { createCredentialStorageContext } from "../credential-store"; import { getAuthFromEnv } from "../credentials"; import { getCloudflareAccountIdFromEnv as getAccountIdFromEnv } from "../env-vars"; import { createOAuthFlow } from "../flow"; +import { AuthenticationError } from "./authentication-error"; import { createFileStorage } from "./file-storage"; import { createPreferences } from "./preferences"; import { createTemporaryTermsPrompt } from "./temporary-terms"; @@ -267,6 +268,7 @@ export function createCloudflareAuth( ), cliDisplayName: descriptor.displayName, logoutCommand: descriptor.commands.logout, + temporaryAccountFlag: descriptor.temporaryAccountFlag, prompt: createTemporaryTermsPrompt({ temporaryAccountLogger, prompt: ctx.prompt, @@ -428,9 +430,10 @@ export function createCloudflareAuth( const { throwOnEmpty = true } = options; const loginResult = await loginOrRefreshIfRequired(complianceConfig); if (!loginResult.loggedIn) { - throw new UserError( + throw new AuthenticationError( `Not logged in. ${NOT_LOGGED_IN_ERROR_BODIES[loginResult.reason]}${NOT_LOGGED_IN_WHOAMI_TIP}`, - { telemetryMessage: "cfetch auth login required" } + loginResult.reason, + "cfetch auth login required" ); } const credentials = requireApiToken(); @@ -603,7 +606,7 @@ ${accounts // rather than silently provisioning a throwaway account alongside them. if (getAPIToken()) { throw new UserError( - "You're already authenticated with Cloudflare, so `--temporary` can't be used. Temporary preview accounts are only for unauthenticated use. Either remove `--temporary` to use your existing account, or log out (and unset CLOUDFLARE_API_TOKEN) first.", + `You're already authenticated with Cloudflare, so \`${descriptor.temporaryAccountFlag ?? "--temporary"}\` can't be used. Temporary preview accounts are only for unauthenticated use. Either remove \`${descriptor.temporaryAccountFlag ?? "--temporary"}\` to use your existing account, or log out (and unset CLOUDFLARE_API_TOKEN) first.`, { telemetryMessage: "user temporary account already authenticated", } diff --git a/packages/workers-auth/src/core/types.ts b/packages/workers-auth/src/core/types.ts index 105324df195..7bd5a2c81c0 100644 --- a/packages/workers-auth/src/core/types.ts +++ b/packages/workers-auth/src/core/types.ts @@ -70,6 +70,9 @@ export interface CliDescriptor { */ displayName: string; + /** Temporary-account flag spelling in user guidance. Defaults to `--temporary`. */ + temporaryAccountFlag?: string; + /** Commands surfaced in auth guidance. */ commands: { login: string; diff --git a/packages/workers-auth/src/index.ts b/packages/workers-auth/src/index.ts index 36ea7b06b38..9592a39053b 100644 --- a/packages/workers-auth/src/index.ts +++ b/packages/workers-auth/src/index.ts @@ -111,3 +111,5 @@ export type { NpmRunner, ScrubEncryptedCredentialsResult, } from "./credential-store"; + +export { AuthenticationError } from "./core/authentication-error"; diff --git a/packages/workers-auth/src/temporary.ts b/packages/workers-auth/src/temporary.ts index bcae4016f0f..37d9702b102 100644 --- a/packages/workers-auth/src/temporary.ts +++ b/packages/workers-auth/src/temporary.ts @@ -24,8 +24,6 @@ export const TEMPORARY_TERMS_URLS = { export const TEMPORARY_TERMS_PROMPT = `You must accept Cloudflare's Terms of Service (${TEMPORARY_TERMS_URLS.termsOfService}) and Privacy Policy (${TEMPORARY_TERMS_URLS.privacyPolicy}) in order to continue. By typing "yes", you agree to these terms. Type "yes" to continue.`; export const TEMPORARY_TERMS_NOTICE = `Continuing means you accept Cloudflare's Terms of Service (${TEMPORARY_TERMS_URLS.termsOfService}) and Privacy Policy (${TEMPORARY_TERMS_URLS.privacyPolicy}).`; -const TEMPORARY_TERMS_ERROR = `You must accept Cloudflare's Terms of Service (${TEMPORARY_TERMS_URLS.termsOfService}) and Privacy Policy (${TEMPORARY_TERMS_URLS.privacyPolicy}) to use --temporary.`; - type TemporaryAccountPayload = { account?: { id?: string; @@ -386,6 +384,8 @@ export async function getOrCreateTemporaryPreviewAccount(options: { temporaryAccountLogger: TemporaryAccountLogger; /** Consumer name used in temporary-account errors. */ cliDisplayName?: string; + /** Consumer temporary-account flag spelling. Defaults to `--temporary`. */ + temporaryAccountFlag?: string; /** Consumer command used to clear a cached temporary account. */ logoutCommand?: string; request?: TemporaryAccountRequest; @@ -414,9 +414,10 @@ export async function getOrCreateTemporaryPreviewAccount(options: { ); if (!termsAccepted) { - throw new UserError(TEMPORARY_TERMS_ERROR, { - telemetryMessage: "user temporary terms not accepted", - }); + throw new UserError( + `You must accept Cloudflare's Terms of Service (${TEMPORARY_TERMS_URLS.termsOfService}) and Privacy Policy (${TEMPORARY_TERMS_URLS.privacyPolicy}) to use ${options.temporaryAccountFlag ?? "--temporary"}.`, + { telemetryMessage: "user temporary terms not accepted" } + ); } const temporaryPreviewAccount = await createTemporaryPreviewAccount( diff --git a/packages/workers-auth/src/wrangler/index.ts b/packages/workers-auth/src/wrangler/index.ts index b1ec8ccb93d..2f3a1c67c68 100644 --- a/packages/workers-auth/src/wrangler/index.ts +++ b/packages/workers-auth/src/wrangler/index.ts @@ -153,3 +153,5 @@ export function setKeyringPreference( } export type { KeyringPreferenceContext } from "../core/keyring-preference"; + +export { AuthenticationError } from "../core/authentication-error"; diff --git a/packages/workers-auth/tests/cf/index.test.ts b/packages/workers-auth/tests/cf/index.test.ts index adeb8ba1b9d..e542ab117b4 100644 --- a/packages/workers-auth/tests/cf/index.test.ts +++ b/packages/workers-auth/tests/cf/index.test.ts @@ -12,6 +12,9 @@ import { readAuthConfigFile, writeAuthConfigFile, } from "../../src/cf"; +import { createCloudflareAuth } from "../../src/core/factory"; +import { createFileStorage } from "../../src/core/file-storage"; +import { WRANGLER_CLI } from "../../src/wrangler"; import type { AuthContext } from "../../src/cf"; import type { UserAuthConfig } from "../../src/config-file/auth"; @@ -146,3 +149,57 @@ describe("cf auth layer", () => { expect(store.configs.exists("work")).toBe(true); }); }); + +describe("cached temporary-account activation", () => { + runInTempDir(); + + it.for([CF_CLI, WRANGLER_CLI])( + "requires explicit opt-in for $cliName on each invocation", + async (descriptor, { expect }) => { + vi.stubEnv("CI", "true"); + for (const key of [ + "CLOUDFLARE_API_TOKEN", + "CLOUDFLARE_API_KEY", + "CLOUDFLARE_EMAIL", + "CLOUDFLARE_ACCOUNT_ID", + ]) { + vi.stubEnv(key, ""); + } + const storage = createFileStorage( + descriptor.fileFormat, + descriptor.getTemporaryAccountConfigPath + ); + storage.write({ + account: { + id: "cached-preview-account", + name: "Preview", + apiToken: "cached-preview-token", + expiresAt: "2999-01-01T00:00:00Z", + }, + claim: { + url: "https://claim.example", + expiresAt: "2999-01-01T00:00:00Z", + }, + }); + const auth = createCloudflareAuth(descriptor, createTestContext()); + + auth.setTemporaryAllowed(false); + await expect(auth.requireAuth({})).rejects.toThrow( + "non-interactive environment" + ); + expect(auth.getAPIToken()).toBeUndefined(); + + auth.setTemporaryAllowed(true); + await expect(auth.requireAuth({})).resolves.toBe( + "cached-preview-account" + ); + expect(auth.requireApiToken()).toEqual({ + apiToken: "cached-preview-token", + }); + + auth.setTemporaryAllowed(false); + expect(auth.getAPIToken()).toBeUndefined(); + expect(storage.read()).toBeDefined(); + } + ); +}); diff --git a/packages/workers-auth/tests/core/factory.test.ts b/packages/workers-auth/tests/core/factory.test.ts index 2321a9d3c96..db1de23e9be 100644 --- a/packages/workers-auth/tests/core/factory.test.ts +++ b/packages/workers-auth/tests/core/factory.test.ts @@ -3,7 +3,9 @@ import { beforeEach, describe, it, vi } from "vitest"; import { CF_CLI } from "../../src/cf"; import { createCloudflareAuth } from "../../src/core/factory"; import { createOAuthFlow } from "../../src/flow"; +import { WRANGLER_CLI } from "../../src/wrangler"; import type { AuthContext } from "../../src/core/types"; +import type { LoginOrRefreshResult } from "../../src/flow"; import type { ComplianceConfig } from "@cloudflare/workers-utils"; const fetchInternalBase = vi.hoisted(() => vi.fn()); @@ -11,10 +13,12 @@ const flow = vi.hoisted(() => ({ activateTemporaryAccount: vi.fn(), getActiveProfile: vi.fn(() => "default"), getActiveTemporaryAccount: vi.fn(() => undefined), - getAPIToken: vi.fn(() => undefined), + getAPIToken: vi.fn((): { apiToken: string } | undefined => undefined), isTemporaryAllowed: vi.fn(() => false), login: vi.fn(async () => true), - loginOrRefreshIfRequired: vi.fn(async () => ({ loggedIn: true as const })), + loginOrRefreshIfRequired: vi.fn(async (): Promise => ({ + loggedIn: true, + })), requireApiToken: vi.fn(() => ({ apiToken: "test-token" })), })); @@ -185,3 +189,63 @@ describe("temporary account status messages", () => { ); }); }); + +describe("consumer auth guidance", () => { + runInTempDir(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.for([ + { descriptor: CF_CLI, flag: "--temporary-account" }, + { descriptor: WRANGLER_CLI, flag: "--temporary" }, + ])( + "uses $flag in existing-credential errors", + async ({ descriptor, flag }, { expect }) => { + flow.isTemporaryAllowed.mockReturnValue(true); + vi.mocked(flow.getAPIToken).mockReturnValue({ + apiToken: "existing-token", + }); + const auth = createCloudflareAuth(descriptor, createTestContext()); + + await expect(auth.requireAuth(COMPLIANCE_CONFIG)).rejects.toThrow( + `Either remove \`${flag}\` to use your existing account` + ); + expect(createOAuthFlow).toHaveBeenCalledWith( + expect.objectContaining({ + temporary: expect.objectContaining({ + temporaryAccountFlag: descriptor.temporaryAccountFlag, + }), + }) + ); + expect(flow.activateTemporaryAccount).not.toHaveBeenCalled(); + } + ); + + it.for([ + "no-credentials-non-interactive", + "no-credentials-login-failed", + "token-expired-non-interactive", + "token-expired-login-failed", + "token-refresh-unreachable", + ] as const)( + "preserves %s on account lookup errors", + async (reason, { expect }) => { + flow.isTemporaryAllowed.mockReturnValue(false); + flow.loginOrRefreshIfRequired.mockResolvedValue({ + loggedIn: false, + reason, + }); + const auth = createCloudflareAuth(CF_CLI, createTestContext()); + + await expect( + auth.fetchAllAccounts(COMPLIANCE_CONFIG) + ).rejects.toMatchObject({ + reason, + telemetryMessage: "cfetch auth login required", + }); + expect(fetchInternalBase).not.toHaveBeenCalled(); + } + ); +}); diff --git a/packages/workers-auth/tests/temporary-event.test.ts b/packages/workers-auth/tests/temporary-event.test.ts index a1d326a53dc..b2e5e46de36 100644 --- a/packages/workers-auth/tests/temporary-event.test.ts +++ b/packages/workers-auth/tests/temporary-event.test.ts @@ -280,3 +280,19 @@ describe("temporary event accounts", () => { expect(String(error)).not.toContain(BACKEND_MESSAGE); }); }); + +describe("consumer temporary flag guidance", () => { + it.for([undefined, "--temporary-account"])( + "uses %s in declined-terms errors", + async (temporaryAccountFlag, { expect }) => { + await expect( + getOrCreateTemporaryPreviewAccount({ + storage: createStorage(), + prompt: async () => false, + temporaryAccountLogger: silentLogger, + temporaryAccountFlag, + }) + ).rejects.toThrow(`to use ${temporaryAccountFlag ?? "--temporary"}.`); + } + ); +}); From d511b6a3e899f997206484ed85eb276493c0ae61 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 20 Oct 2026 14:24:29 +0100 Subject: [PATCH 2/6] test: focus auth regression coverage --- packages/workers-auth/tests/cf/index.test.ts | 57 ------------- .../workers-auth/tests/core/factory.test.ts | 79 +++++++------------ .../tests/temporary-event.test.ts | 16 ---- 3 files changed, 28 insertions(+), 124 deletions(-) diff --git a/packages/workers-auth/tests/cf/index.test.ts b/packages/workers-auth/tests/cf/index.test.ts index e542ab117b4..adeb8ba1b9d 100644 --- a/packages/workers-auth/tests/cf/index.test.ts +++ b/packages/workers-auth/tests/cf/index.test.ts @@ -12,9 +12,6 @@ import { readAuthConfigFile, writeAuthConfigFile, } from "../../src/cf"; -import { createCloudflareAuth } from "../../src/core/factory"; -import { createFileStorage } from "../../src/core/file-storage"; -import { WRANGLER_CLI } from "../../src/wrangler"; import type { AuthContext } from "../../src/cf"; import type { UserAuthConfig } from "../../src/config-file/auth"; @@ -149,57 +146,3 @@ describe("cf auth layer", () => { expect(store.configs.exists("work")).toBe(true); }); }); - -describe("cached temporary-account activation", () => { - runInTempDir(); - - it.for([CF_CLI, WRANGLER_CLI])( - "requires explicit opt-in for $cliName on each invocation", - async (descriptor, { expect }) => { - vi.stubEnv("CI", "true"); - for (const key of [ - "CLOUDFLARE_API_TOKEN", - "CLOUDFLARE_API_KEY", - "CLOUDFLARE_EMAIL", - "CLOUDFLARE_ACCOUNT_ID", - ]) { - vi.stubEnv(key, ""); - } - const storage = createFileStorage( - descriptor.fileFormat, - descriptor.getTemporaryAccountConfigPath - ); - storage.write({ - account: { - id: "cached-preview-account", - name: "Preview", - apiToken: "cached-preview-token", - expiresAt: "2999-01-01T00:00:00Z", - }, - claim: { - url: "https://claim.example", - expiresAt: "2999-01-01T00:00:00Z", - }, - }); - const auth = createCloudflareAuth(descriptor, createTestContext()); - - auth.setTemporaryAllowed(false); - await expect(auth.requireAuth({})).rejects.toThrow( - "non-interactive environment" - ); - expect(auth.getAPIToken()).toBeUndefined(); - - auth.setTemporaryAllowed(true); - await expect(auth.requireAuth({})).resolves.toBe( - "cached-preview-account" - ); - expect(auth.requireApiToken()).toEqual({ - apiToken: "cached-preview-token", - }); - - auth.setTemporaryAllowed(false); - expect(auth.getAPIToken()).toBeUndefined(); - expect(storage.read()).toBeDefined(); - } - ); -}); diff --git a/packages/workers-auth/tests/core/factory.test.ts b/packages/workers-auth/tests/core/factory.test.ts index db1de23e9be..62d54638ad3 100644 --- a/packages/workers-auth/tests/core/factory.test.ts +++ b/packages/workers-auth/tests/core/factory.test.ts @@ -3,7 +3,6 @@ import { beforeEach, describe, it, vi } from "vitest"; import { CF_CLI } from "../../src/cf"; import { createCloudflareAuth } from "../../src/core/factory"; import { createOAuthFlow } from "../../src/flow"; -import { WRANGLER_CLI } from "../../src/wrangler"; import type { AuthContext } from "../../src/core/types"; import type { LoginOrRefreshResult } from "../../src/flow"; import type { ComplianceConfig } from "@cloudflare/workers-utils"; @@ -197,55 +196,33 @@ describe("consumer auth guidance", () => { vi.clearAllMocks(); }); - it.for([ - { descriptor: CF_CLI, flag: "--temporary-account" }, - { descriptor: WRANGLER_CLI, flag: "--temporary" }, - ])( - "uses $flag in existing-credential errors", - async ({ descriptor, flag }, { expect }) => { - flow.isTemporaryAllowed.mockReturnValue(true); - vi.mocked(flow.getAPIToken).mockReturnValue({ - apiToken: "existing-token", - }); - const auth = createCloudflareAuth(descriptor, createTestContext()); - - await expect(auth.requireAuth(COMPLIANCE_CONFIG)).rejects.toThrow( - `Either remove \`${flag}\` to use your existing account` - ); - expect(createOAuthFlow).toHaveBeenCalledWith( - expect.objectContaining({ - temporary: expect.objectContaining({ - temporaryAccountFlag: descriptor.temporaryAccountFlag, - }), - }) - ); - expect(flow.activateTemporaryAccount).not.toHaveBeenCalled(); - } - ); - - it.for([ - "no-credentials-non-interactive", - "no-credentials-login-failed", - "token-expired-non-interactive", - "token-expired-login-failed", - "token-refresh-unreachable", - ] as const)( - "preserves %s on account lookup errors", - async (reason, { expect }) => { - flow.isTemporaryAllowed.mockReturnValue(false); - flow.loginOrRefreshIfRequired.mockResolvedValue({ - loggedIn: false, - reason, - }); - const auth = createCloudflareAuth(CF_CLI, createTestContext()); + it("uses cf's temporary-account flag in auth guidance", async ({ + expect, + }) => { + flow.isTemporaryAllowed.mockReturnValue(true); + flow.getAPIToken.mockReturnValue({ apiToken: "existing-token" }); + const auth = createCloudflareAuth(CF_CLI, createTestContext()); - await expect( - auth.fetchAllAccounts(COMPLIANCE_CONFIG) - ).rejects.toMatchObject({ - reason, - telemetryMessage: "cfetch auth login required", - }); - expect(fetchInternalBase).not.toHaveBeenCalled(); - } - ); + await expect(auth.requireAuth(COMPLIANCE_CONFIG)).rejects.toThrow( + "Either remove `--temporary-account` to use your existing account" + ); + }); + + it("preserves the authentication failure reason on account lookup", async ({ + expect, + }) => { + flow.isTemporaryAllowed.mockReturnValue(false); + flow.loginOrRefreshIfRequired.mockResolvedValue({ + loggedIn: false, + reason: "no-credentials-non-interactive", + }); + const auth = createCloudflareAuth(CF_CLI, createTestContext()); + + await expect( + auth.fetchAllAccounts(COMPLIANCE_CONFIG) + ).rejects.toMatchObject({ + reason: "no-credentials-non-interactive", + telemetryMessage: "cfetch auth login required", + }); + }); }); diff --git a/packages/workers-auth/tests/temporary-event.test.ts b/packages/workers-auth/tests/temporary-event.test.ts index b2e5e46de36..a1d326a53dc 100644 --- a/packages/workers-auth/tests/temporary-event.test.ts +++ b/packages/workers-auth/tests/temporary-event.test.ts @@ -280,19 +280,3 @@ describe("temporary event accounts", () => { expect(String(error)).not.toContain(BACKEND_MESSAGE); }); }); - -describe("consumer temporary flag guidance", () => { - it.for([undefined, "--temporary-account"])( - "uses %s in declined-terms errors", - async (temporaryAccountFlag, { expect }) => { - await expect( - getOrCreateTemporaryPreviewAccount({ - storage: createStorage(), - prompt: async () => false, - temporaryAccountLogger: silentLogger, - temporaryAccountFlag, - }) - ).rejects.toThrow(`to use ${temporaryAccountFlag ?? "--temporary"}.`); - } - ); -}); From 07e84f2a79087de271702739bf8207c850242bb9 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 20 Oct 2026 15:02:45 +0100 Subject: [PATCH 3/6] fix(workers-auth): address review guidance and preserve telemetry --- .changeset/auth-consumer-guidance.md | 2 +- packages/workers-auth/src/core/factory.ts | 11 +++++++---- packages/workers-auth/src/temporary.ts | 5 ++++- .../src/__tests__/core/handle-errors.test.ts | 13 +++++++++++++ packages/wrangler/src/core/handle-errors.ts | 5 +++++ 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.changeset/auth-consumer-guidance.md b/.changeset/auth-consumer-guidance.md index e82948aa340..9af6d0ad61c 100644 --- a/.changeset/auth-consumer-guidance.md +++ b/.changeset/auth-consumer-guidance.md @@ -1,5 +1,5 @@ --- -"@cloudflare/workers-auth": patch +"@cloudflare/workers-auth": minor --- Use each CLI's temporary-account flag in authentication errors diff --git a/packages/workers-auth/src/core/factory.ts b/packages/workers-auth/src/core/factory.ts index 3b20957fd77..fd4e44dfffb 100644 --- a/packages/workers-auth/src/core/factory.ts +++ b/packages/workers-auth/src/core/factory.ts @@ -32,6 +32,7 @@ import { createCredentialStorageContext } from "../credential-store"; import { getAuthFromEnv } from "../credentials"; import { getCloudflareAccountIdFromEnv as getAccountIdFromEnv } from "../env-vars"; import { createOAuthFlow } from "../flow"; +import { DEFAULT_TEMPORARY_ACCOUNT_FLAG } from "../temporary"; import { AuthenticationError } from "./authentication-error"; import { createFileStorage } from "./file-storage"; import { createPreferences } from "./preferences"; @@ -85,7 +86,7 @@ export interface CloudflareAuth { getActiveProfile: () => string; /** The currently-active credential store for the active profile. */ getCredentialStore: () => CredentialStore; - /** Mark whether `--temporary` is permitted for the current invocation. */ + /** Mark whether temporary-account mode is permitted for the current invocation. */ setTemporaryAllowed: ( allowed: boolean, request?: TemporaryAccountRequest @@ -198,6 +199,8 @@ export function createCloudflareAuth( const { logger } = ctx; const temporaryAccountLogger = ctx.temporaryAccountLogger ?? logger; const cliName = descriptor.cliName; + const temporaryAccountFlag = + descriptor.temporaryAccountFlag ?? DEFAULT_TEMPORARY_ACCOUNT_FLAG; const NOT_LOGGED_IN_ERROR_BODIES = notLoggedInErrorBodies( descriptor.commands.login ); @@ -268,7 +271,7 @@ export function createCloudflareAuth( ), cliDisplayName: descriptor.displayName, logoutCommand: descriptor.commands.logout, - temporaryAccountFlag: descriptor.temporaryAccountFlag, + temporaryAccountFlag, prompt: createTemporaryTermsPrompt({ temporaryAccountLogger, prompt: ctx.prompt, @@ -601,12 +604,12 @@ ${accounts return latchedTemporaryAccount.account.id; } - // `--temporary` is only for unauthenticated use. If any credentials are + // Temporary-account mode is only for unauthenticated use. If credentials are // already available (env, global key, or a stored OAuth token), refuse // rather than silently provisioning a throwaway account alongside them. if (getAPIToken()) { throw new UserError( - `You're already authenticated with Cloudflare, so \`${descriptor.temporaryAccountFlag ?? "--temporary"}\` can't be used. Temporary preview accounts are only for unauthenticated use. Either remove \`${descriptor.temporaryAccountFlag ?? "--temporary"}\` to use your existing account, or log out (and unset CLOUDFLARE_API_TOKEN) first.`, + `You're already authenticated with Cloudflare, so \`${temporaryAccountFlag}\` can't be used. Temporary preview accounts are only for unauthenticated use. Either remove \`${temporaryAccountFlag}\` to use your existing account, or log out (and unset CLOUDFLARE_API_TOKEN) first.`, { telemetryMessage: "user temporary account already authenticated", } diff --git a/packages/workers-auth/src/temporary.ts b/packages/workers-auth/src/temporary.ts index 37d9702b102..20cfe7dce9a 100644 --- a/packages/workers-auth/src/temporary.ts +++ b/packages/workers-auth/src/temporary.ts @@ -16,6 +16,9 @@ import type { } from "./context"; import type { PowSolution } from "./pow"; +/** Flag spelling used by consumers that don't specify their own. */ +export const DEFAULT_TEMPORARY_ACCOUNT_FLAG = "--temporary"; + export const TEMPORARY_TERMS_URLS = { termsOfService: "https://www.cloudflare.com/terms/", privacyPolicy: "https://www.cloudflare.com/privacypolicy/", @@ -415,7 +418,7 @@ export async function getOrCreateTemporaryPreviewAccount(options: { if (!termsAccepted) { throw new UserError( - `You must accept Cloudflare's Terms of Service (${TEMPORARY_TERMS_URLS.termsOfService}) and Privacy Policy (${TEMPORARY_TERMS_URLS.privacyPolicy}) to use ${options.temporaryAccountFlag ?? "--temporary"}.`, + `You must accept Cloudflare's Terms of Service (${TEMPORARY_TERMS_URLS.termsOfService}) and Privacy Policy (${TEMPORARY_TERMS_URLS.privacyPolicy}) to use ${options.temporaryAccountFlag ?? DEFAULT_TEMPORARY_ACCOUNT_FLAG}.`, { telemetryMessage: "user temporary terms not accepted" } ); } diff --git a/packages/wrangler/src/__tests__/core/handle-errors.test.ts b/packages/wrangler/src/__tests__/core/handle-errors.test.ts index 4d2d8983b3a..729e394bf51 100644 --- a/packages/wrangler/src/__tests__/core/handle-errors.test.ts +++ b/packages/wrangler/src/__tests__/core/handle-errors.test.ts @@ -1,3 +1,4 @@ +import { AuthenticationError } from "@cloudflare/workers-auth/wrangler"; import { APIError, ParseError } from "@cloudflare/workers-utils"; import { Cloudflare } from "cloudflare"; import { beforeEach, describe, it, vi } from "vitest"; @@ -263,6 +264,18 @@ describe("getErrorType", () => { }); describe("Authentication errors", () => { + it("preserves the UserError telemetry type for account lookup failures", ({ + expect, + }) => { + const error = new AuthenticationError( + "Not logged in.", + "no-credentials-non-interactive", + "cfetch auth login required" + ); + + expect(getErrorType(error)).toBe("UserError"); + }); + it("should return 'AuthenticationError' for code 10000 (expired/revoked token)", ({ expect, }) => { diff --git a/packages/wrangler/src/core/handle-errors.ts b/packages/wrangler/src/core/handle-errors.ts index a493fe9b27f..53e5cffd0e5 100644 --- a/packages/wrangler/src/core/handle-errors.ts +++ b/packages/wrangler/src/core/handle-errors.ts @@ -1,5 +1,6 @@ import assert from "node:assert"; import { ApiError } from "@cloudflare/containers-shared"; +import { AuthenticationError } from "@cloudflare/workers-auth/wrangler"; import { APIError, CommandLineArgsError, @@ -305,6 +306,10 @@ export function getErrorType(e: unknown): string | undefined { if (isBuildFailure(e) || isBuildFailureFromCause(e)) { return "BuildFailure"; } + // Account lookup used UserError before exposing structured auth failures. + if (e instanceof AuthenticationError) { + return "UserError"; + } return e instanceof Error ? e.constructor.name : undefined; } From 23f56ee9f1ec66caf249ccb4a4c89fb332208a61 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 20 Oct 2026 15:02:48 +0100 Subject: [PATCH 4/6] fix(wrangler): drain unbundled rebuilds during teardown --- .changeset/drain-unbundled-rebuilds.md | 7 +++ .../startDevWorker/BundleController.test.ts | 48 +++++++++++++++++++ packages/wrangler/src/dev/use-esbuild.ts | 19 ++++++-- 3 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 .changeset/drain-unbundled-rebuilds.md diff --git a/.changeset/drain-unbundled-rebuilds.md b/.changeset/drain-unbundled-rebuilds.md new file mode 100644 index 00000000000..21af711d6bf --- /dev/null +++ b/.changeset/drain-unbundled-rebuilds.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +Wait for unbundled rebuilds before shutting down development file watchers + +Prevent in-flight module scans from reading deleted build directories during teardown, and dispose of the entrypoint build context when watching unbundled Workers. diff --git a/packages/wrangler/src/__tests__/api/startDevWorker/BundleController.test.ts b/packages/wrangler/src/__tests__/api/startDevWorker/BundleController.test.ts index e95b994c86c..89ac1cd2434 100644 --- a/packages/wrangler/src/__tests__/api/startDevWorker/BundleController.test.ts +++ b/packages/wrangler/src/__tests__/api/startDevWorker/BundleController.test.ts @@ -1,3 +1,4 @@ +import assert from "node:assert"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { setTimeout } from "node:timers/promises"; @@ -6,15 +7,22 @@ import { runInTempDir, seed, } from "@cloudflare/workers-utils/test-helpers"; +import * as chokidar from "chokidar"; import * as esbuild from "esbuild"; import dedent from "ts-dedent"; import { afterEach, beforeEach, describe, test, vi } from "vitest"; import { BundlerController } from "../../../api/startDevWorker/BundlerController"; +import * as additionalModules from "../../../deployment-bundle/find-additional-modules"; import { runCustomBuild } from "../../../deployment-bundle/run-custom-build"; import { FakeBus } from "../../helpers/fake-bus"; import { mockConsoleMethods } from "../../helpers/mock-console"; import type { StartDevWorkerOptions } from "../../../api"; +vi.mock("chokidar", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, watch: vi.fn(actual.watch) }; +}); + // Find the bundled result of a particular source file function findSourceFile(source: string, name: string): string { const startIndex = source.indexOf(`// ${name}`); @@ -285,6 +293,46 @@ describe("BundleController", { retry: 5, timeout: 10_000 }, () => { } ); + test("teardown waits for an in-flight unbundled module scan", async ({ + expect, + }) => { + await seed({ "src/index.ts": "export class Api {}" }); + const watch = vi.spyOn(chokidar, "watch"); + const initialBuild = bus.waitFor("bundleComplete"); + controller.onConfigUpdate({ + type: "configUpdate", + config: configDefaults({ build: { bundle: false } }), + }); + await initialBuild; + const watcher = watch.mock.results.at(-1)?.value; + assert(watcher); + + const started = Promise.withResolvers(); + const resume = Promise.withResolvers(); + const findModules = additionalModules.findAdditionalModules; + const scan = vi + .spyOn(additionalModules, "findAdditionalModules") + .mockImplementationOnce(async (...args) => { + started.resolve(); + await resume.promise; + return findModules(...args); + }); + watcher.emit("change", path.resolve("src/index.ts")); + await started.promise; + const finished = vi.fn(); + const teardown = controller.teardown().then(finished); + try { + await watcher.close(); + await setTimeout(0); + expect(finished).not.toHaveBeenCalled(); + } finally { + resume.resolve(); + await teardown; + scan.mockRestore(); + watch.mockRestore(); + } + }); + test("a watch-mode rebuild failure emits an error event and recovers", async ({ expect, }) => { diff --git a/packages/wrangler/src/dev/use-esbuild.ts b/packages/wrangler/src/dev/use-esbuild.ts index 5d9dc70f160..10cb44552df 100644 --- a/packages/wrangler/src/dev/use-esbuild.ts +++ b/packages/wrangler/src/dev/use-esbuild.ts @@ -287,6 +287,7 @@ export function runBuild( // if "noBundle" is true, then we need to manually watch all modules and // trigger "builds" when any change if (noBundle && watch) { + let pendingRebuild = Promise.resolve(); const watching = [path.resolve(entry.moduleRoot)]; const watcher = watchPaths(watching, { persistent: true, @@ -296,12 +297,22 @@ export function runBuild( // chokidar v4 normalises paths to forward slashes before // matching, so a regex on path segments works cross-platform. ignored: /[/\\](\.git|node_modules|\.wrangler)([/\\]|$)/, - }).on("change", async (_event) => { - await refreshExports(); - await updateBundle(); + }).on("change", () => { + pendingRebuild = pendingRebuild + .then(async () => { + await refreshExports(); + await updateBundle(); + }) + .catch(onErr); }); - stopWatching = () => watcher.close(); + stopWatching = async () => { + await watcher.close(); + // Closing chokidar doesn't wait for asynchronous change handlers. + // Drain them before callers remove the source and build directories. + await pendingRebuild; + await bundleResult?.stop?.(); + }; } const entrypointPath = realpathSync( bundleResult?.resolvedEntryPointPath ?? entry.file From bbd5b64494ef7d0febdce1eb8ccddb7fbe12a69c Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 20 Oct 2026 19:30:27 +0100 Subject: [PATCH 5/6] fix(workers-auth): default temporary account flag to cf --- .changeset/auth-consumer-guidance.md | 2 +- packages/workers-auth/src/context.ts | 2 +- packages/workers-auth/src/core/types.ts | 2 +- packages/workers-auth/src/temporary.ts | 4 ++-- packages/workers-auth/src/wrangler/index.ts | 1 + packages/workers-auth/tests/core/factory.test.ts | 7 +++++-- 6 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.changeset/auth-consumer-guidance.md b/.changeset/auth-consumer-guidance.md index 9af6d0ad61c..168519bb609 100644 --- a/.changeset/auth-consumer-guidance.md +++ b/.changeset/auth-consumer-guidance.md @@ -4,4 +4,4 @@ Use each CLI's temporary-account flag in authentication errors -Keep Wrangler's `--temporary` spelling and use `--temporary-account` for cf. Expose account-lookup authentication failure reasons so consumers can distinguish missing credentials from expired tokens without matching error messages. +Default to `--temporary-account` while keeping Wrangler's `--temporary` spelling. Expose account-lookup authentication failure reasons so consumers can distinguish missing credentials from expired tokens without matching error messages. diff --git a/packages/workers-auth/src/context.ts b/packages/workers-auth/src/context.ts index 44276be6229..79ec3731379 100644 --- a/packages/workers-auth/src/context.ts +++ b/packages/workers-auth/src/context.ts @@ -13,7 +13,7 @@ export interface OAuthFlowTemporaryContext { storage: TemporaryAccountStorage; /** Consumer name used in temporary-account errors. */ cliDisplayName?: string; - /** Consumer temporary-account flag spelling. Defaults to `--temporary`. */ + /** Consumer temporary-account flag spelling. Defaults to `--temporary-account`. */ temporaryAccountFlag?: string; /** Consumer command used to clear a cached temporary account. */ logoutCommand?: string; diff --git a/packages/workers-auth/src/core/types.ts b/packages/workers-auth/src/core/types.ts index 7bd5a2c81c0..015d3273043 100644 --- a/packages/workers-auth/src/core/types.ts +++ b/packages/workers-auth/src/core/types.ts @@ -70,7 +70,7 @@ export interface CliDescriptor { */ displayName: string; - /** Temporary-account flag spelling in user guidance. Defaults to `--temporary`. */ + /** Temporary-account flag spelling in user guidance. Defaults to `--temporary-account`. */ temporaryAccountFlag?: string; /** Commands surfaced in auth guidance. */ diff --git a/packages/workers-auth/src/temporary.ts b/packages/workers-auth/src/temporary.ts index 20cfe7dce9a..618d1a4f14d 100644 --- a/packages/workers-auth/src/temporary.ts +++ b/packages/workers-auth/src/temporary.ts @@ -17,7 +17,7 @@ import type { import type { PowSolution } from "./pow"; /** Flag spelling used by consumers that don't specify their own. */ -export const DEFAULT_TEMPORARY_ACCOUNT_FLAG = "--temporary"; +export const DEFAULT_TEMPORARY_ACCOUNT_FLAG = "--temporary-account"; export const TEMPORARY_TERMS_URLS = { termsOfService: "https://www.cloudflare.com/terms/", @@ -387,7 +387,7 @@ export async function getOrCreateTemporaryPreviewAccount(options: { temporaryAccountLogger: TemporaryAccountLogger; /** Consumer name used in temporary-account errors. */ cliDisplayName?: string; - /** Consumer temporary-account flag spelling. Defaults to `--temporary`. */ + /** Consumer temporary-account flag spelling. Defaults to `--temporary-account`. */ temporaryAccountFlag?: string; /** Consumer command used to clear a cached temporary account. */ logoutCommand?: string; diff --git a/packages/workers-auth/src/wrangler/index.ts b/packages/workers-auth/src/wrangler/index.ts index 2f3a1c67c68..414d3cf58d5 100644 --- a/packages/workers-auth/src/wrangler/index.ts +++ b/packages/workers-auth/src/wrangler/index.ts @@ -70,6 +70,7 @@ export type WranglerLoginProps = CloudflareLoginProps; export const WRANGLER_CLI: CliDescriptor = { cliName: WRANGLER_CLI_NAME, displayName: WRANGLER_DISPLAY_NAME, + temporaryAccountFlag: "--temporary", commands: { login: "wrangler login", logout: "wrangler logout", diff --git a/packages/workers-auth/tests/core/factory.test.ts b/packages/workers-auth/tests/core/factory.test.ts index 62d54638ad3..cb676f4d0a9 100644 --- a/packages/workers-auth/tests/core/factory.test.ts +++ b/packages/workers-auth/tests/core/factory.test.ts @@ -196,12 +196,15 @@ describe("consumer auth guidance", () => { vi.clearAllMocks(); }); - it("uses cf's temporary-account flag in auth guidance", async ({ + it("defaults to cf's temporary-account flag in auth guidance", async ({ expect, }) => { flow.isTemporaryAllowed.mockReturnValue(true); flow.getAPIToken.mockReturnValue({ apiToken: "existing-token" }); - const auth = createCloudflareAuth(CF_CLI, createTestContext()); + const auth = createCloudflareAuth( + { ...CF_CLI, temporaryAccountFlag: undefined }, + createTestContext() + ); await expect(auth.requireAuth(COMPLIANCE_CONFIG)).rejects.toThrow( "Either remove `--temporary-account` to use your existing account" From 870d3f7d8154fb2c8c7983dae80878e2c4e894bd Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Tue, 20 Oct 2026 20:04:47 +0100 Subject: [PATCH 6/6] chore: remove watcher teardown changeset --- .changeset/drain-unbundled-rebuilds.md | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .changeset/drain-unbundled-rebuilds.md diff --git a/.changeset/drain-unbundled-rebuilds.md b/.changeset/drain-unbundled-rebuilds.md deleted file mode 100644 index 21af711d6bf..00000000000 --- a/.changeset/drain-unbundled-rebuilds.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"wrangler": patch ---- - -Wait for unbundled rebuilds before shutting down development file watchers - -Prevent in-flight module scans from reading deleted build directories during teardown, and dispose of the entrypoint build context when watching unbundled Workers.