diff --git a/.changeset/vite-plugin-remote-bindings-cf-auth.md b/.changeset/vite-plugin-remote-bindings-cf-auth.md new file mode 100644 index 00000000000..3634434fd25 --- /dev/null +++ b/.changeset/vite-plugin-remote-bindings-cf-auth.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/vite-plugin": patch +--- + +Use cf credentials for remote bindings in `vite dev` and `vite preview` + +Remote bindings previously used cf credentials only when Vite was started by `cf dev`. If remote bindings report that you aren't authenticated, run `cf auth login` or set `CLOUDFLARE_API_TOKEN`. diff --git a/packages/vite-plugin-cloudflare/e2e/remote-bindings.test.ts b/packages/vite-plugin-cloudflare/e2e/remote-bindings.test.ts index 287240136b0..6682aa89eb5 100644 --- a/packages/vite-plugin-cloudflare/e2e/remote-bindings.test.ts +++ b/packages/vite-plugin-cloudflare/e2e/remote-bindings.test.ts @@ -204,30 +204,32 @@ describe.skipIf(isWindows)( test("uses directory-bound auth profile instead of triggering oauth", async ({ expect, }) => { - // Set up a fake HOME with a .wrangler global config directory containing: + // Set up a fake HOME with a cf global config directory containing: // - a named profile with a dummy oauth token // - a directory binding mapping the project path to that profile const fakeHome = path.join(projectPath, ".fake-home"); - const wranglerDir = path.join(fakeHome, ".wrangler"); + const xdgConfigHome = path.join(fakeHome, ".config"); + const cfDir = path.join(xdgConfigHome, "cloudflare"); - mkdirSync(path.join(wranglerDir, "config"), { recursive: true }); + mkdirSync(path.join(cfDir, "config"), { recursive: true }); writeFileSync( - path.join(wranglerDir, "config", "e2e-test-profile.toml"), - [ - 'oauth_token = "fake-oauth-token-for-e2e-test"', - 'refresh_token = "fake-refresh-token"', - `expiration_time = "2099-01-01T00:00:00+00:00"`, - ].join("\n") + path.join(cfDir, "config", "e2e-test-profile.json"), + JSON.stringify({ + oauth_token: "fake-oauth-token-for-e2e-test", + refresh_token: "fake-refresh-token", + expiration_time: "2099-01-01T00:00:00+00:00", + }) ); - mkdirSync(path.join(wranglerDir, "profiles"), { recursive: true }); + mkdirSync(path.join(cfDir, "profiles"), { recursive: true }); writeFileSync( - path.join(wranglerDir, "profiles", "directory-bindings.json"), + path.join(cfDir, "profiles", "directory-bindings.json"), JSON.stringify({ [projectPath]: "e2e-test-profile" }) ); const proc = await runLongLived("pnpm", "dev", projectPath, { HOME: fakeHome, + XDG_CONFIG_HOME: xdgConfigHome, CLOUDFLARE_API_TOKEN: undefined, CLOUDFLARE_API_KEY: undefined, CLOUDFLARE_EMAIL: undefined, diff --git a/packages/vite-plugin-cloudflare/src/__tests__/auth.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/auth.spec.ts new file mode 100644 index 00000000000..80b99761b2e --- /dev/null +++ b/packages/vite-plugin-cloudflare/src/__tests__/auth.spec.ts @@ -0,0 +1,45 @@ +import assert from "node:assert"; +import { createLogger as createViteLogger } from "vite"; +import { test, vi } from "vitest"; +import { createLogger, getRemoteBindingsAuth } from "../auth"; +import type { AsyncHook, CfAccount } from "@cloudflare/workers-utils"; + +const mocks = vi.hoisted(() => ({ + cfAuth: { + setProfile: vi.fn(), + requireAuth: vi.fn().mockResolvedValue("cf-account-id"), + requireApiToken: vi.fn().mockReturnValue({ apiToken: "cf-token" }), + }, + resolveProfile: vi.fn().mockReturnValue("cf-profile"), +})); + +vi.mock("@cloudflare/workers-auth/cf", () => ({ + createCfAuth: () => mocks.cfAuth, + createCfProfileStore: () => ({ resolve: mocks.resolveProfile }), +})); + +test("remote bindings auth uses the cf profile for the project", async ({ + expect, +}) => { + const hooks = new Map>(); + const logger = createLogger(createViteLogger("silent")); + const auth = getRemoteBindingsAuth(hooks, "/project", "account-id", logger); + assert(typeof auth === "function"); + + await expect(auth()).resolves.toEqual({ + accountId: "cf-account-id", + apiToken: { apiToken: "cf-token" }, + }); + expect(mocks.resolveProfile).toHaveBeenCalledWith({ cwd: "/project" }); + expect(mocks.cfAuth.setProfile).toHaveBeenCalledWith("cf-profile"); + expect(mocks.cfAuth.requireAuth).toHaveBeenCalledWith({ + account_id: "account-id", + }); + // A different hook would make the next options update restart the session + expect(getRemoteBindingsAuth(hooks, "/project", "account-id", logger)).toBe( + auth + ); + expect( + getRemoteBindingsAuth(hooks, "/project", "other-account", logger) + ).not.toBe(auth); +}); diff --git a/packages/vite-plugin-cloudflare/src/auth.ts b/packages/vite-plugin-cloudflare/src/auth.ts index fa1f5d76422..43693341ffb 100644 --- a/packages/vite-plugin-cloudflare/src/auth.ts +++ b/packages/vite-plugin-cloudflare/src/auth.ts @@ -7,7 +7,8 @@ import { } from "@cloudflare/workers-auth/cf"; import { isNonInteractiveOrCI } from "@cloudflare/workers-utils"; import { debuglog } from "./utils"; -import type { Logger } from "@cloudflare/workers-utils"; +import type { CloudflareAuth } from "@cloudflare/workers-auth/cf"; +import type { AsyncHook, CfAccount, Logger } from "@cloudflare/workers-utils"; import type * as vite from "vite"; class AccountSelectionUnavailable extends Error { @@ -69,6 +70,43 @@ export function createAuth(profileDir: string, logger: Logger) { return auth; } +/** + * Get the cf auth hook for remote bindings sessions. + * + * Hooks are cached because `maybeStartOrUpdateRemoteProxySession()` restarts + * the session whenever it receives a different hook. + * + * @param hooks - Hook cache that persists across Vite server restarts. + * @param profileDir - Directory used to resolve the cf auth profile. + * @param accountId - The configured account ID, if any. + * @param logger - Logger for the auth flow. + * @returns A hook resolving the account ID and API token for the session. + */ +export function getRemoteBindingsAuth( + hooks: Map>, + profileDir: string, + accountId: string | undefined, + logger: Logger +): AsyncHook { + const key = JSON.stringify([profileDir, accountId]); + let hook = hooks.get(key); + if (hook === undefined) { + let auth: CloudflareAuth | undefined; + hook = async () => { + // Only resolve the profile once a session actually needs credentials + auth ??= createAuth(profileDir, logger); + return { + accountId: await auth.requireAuth( + accountId ? { account_id: accountId } : {} + ), + apiToken: auth.requireApiToken(), + }; + }; + hooks.set(key, hook); + } + return hook; +} + /** Adapt Vite's logger to the shared Cloudflare API logger interface. */ export function createLogger(logger: vite.Logger): Logger { return { diff --git a/packages/vite-plugin-cloudflare/src/context.ts b/packages/vite-plugin-cloudflare/src/context.ts index 2198b46b141..53cdcb14742 100644 --- a/packages/vite-plugin-cloudflare/src/context.ts +++ b/packages/vite-plugin-cloudflare/src/context.ts @@ -18,6 +18,7 @@ import type { ParsedInputWorkerConfig, ParsedOutputWorkerConfig, } from "@cloudflare/config"; +import type { AsyncHook, CfAccount } from "@cloudflare/workers-utils"; import type { MiniflareOptions } from "miniflare"; import type * as vite from "vite"; @@ -34,6 +35,8 @@ export interface SharedContext { configChangeCoordinator?: ConfigChangeCoordinator; /** Allowed hostnames for tunnel connections */ tunnelHostnames: Set; + /** Remote bindings auth hooks, reused so remote proxy sessions are not restarted */ + remoteBindingsAuthHooks?: Map>; } /** An explicitly typed additional module emitted by a Vite environment. */ @@ -118,6 +121,11 @@ export class PluginContext { return this.#sharedContext.workerNameToExportTypesMap; } + get remoteBindingsAuthHooks(): Map> { + this.#sharedContext.remoteBindingsAuthHooks ??= new Map(); + return this.#sharedContext.remoteBindingsAuthHooks; + } + beginRestartingDevServer(): void { this.#sharedContext.restartingDevServerCount++; } diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index b2ec8bb606f..c711e1c677d 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -28,6 +28,7 @@ import { Response as MiniflareResponse, } from "miniflare"; import { getAssetsConfig } from "./asset-config"; +import { createLogger, getRemoteBindingsAuth } from "./auth"; import { ASSET_WORKER_NAME, kRequestType, @@ -527,7 +528,12 @@ export async function getDevMiniflareOptions( profileDir: resolvedViteConfig.root, }, preExistingRemoteProxySession ?? null, - undefined, + getRemoteBindingsAuth( + ctx.remoteBindingsAuthHooks, + resolvedViteConfig.root, + settings.accountId, + createLogger(viteDevServer.config.logger) + ), { cliDisplayName: resolvedPluginConfig.cliDisplayName, logger: createRemoteBindingsLogger( @@ -853,7 +859,12 @@ export async function getPreviewMiniflareOptions( profileDir: resolvedViteConfig.root, }, preExistingRemoteProxySessionData ?? null, - undefined, + getRemoteBindingsAuth( + ctx.remoteBindingsAuthHooks, + resolvedViteConfig.root, + resolvedPluginConfig.settings.accountId, + createLogger(vitePreviewServer.config.logger) + ), { cliDisplayName: resolvedPluginConfig.cliDisplayName, logger: createRemoteBindingsLogger(