Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/vite-plugin-remote-bindings-cf-auth.md
Original file line number Diff line number Diff line change
@@ -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`.
24 changes: 13 additions & 11 deletions packages/vite-plugin-cloudflare/e2e/remote-bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 45 additions & 0 deletions packages/vite-plugin-cloudflare/src/__tests__/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, AsyncHook<CfAccount>>();
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);
});
40 changes: 39 additions & 1 deletion packages/vite-plugin-cloudflare/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, AsyncHook<CfAccount>>,
profileDir: string,
accountId: string | undefined,
logger: Logger
): AsyncHook<CfAccount> {
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 {
Expand Down
8 changes: 8 additions & 0 deletions packages/vite-plugin-cloudflare/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -34,6 +35,8 @@ export interface SharedContext {
configChangeCoordinator?: ConfigChangeCoordinator;
/** Allowed hostnames for tunnel connections */
tunnelHostnames: Set<string>;
/** Remote bindings auth hooks, reused so remote proxy sessions are not restarted */
remoteBindingsAuthHooks?: Map<string, AsyncHook<CfAccount>>;
}

/** An explicitly typed additional module emitted by a Vite environment. */
Expand Down Expand Up @@ -118,6 +121,11 @@ export class PluginContext {
return this.#sharedContext.workerNameToExportTypesMap;
}

get remoteBindingsAuthHooks(): Map<string, AsyncHook<CfAccount>> {
this.#sharedContext.remoteBindingsAuthHooks ??= new Map();
return this.#sharedContext.remoteBindingsAuthHooks;
}

beginRestartingDevServer(): void {
this.#sharedContext.restartingDevServerCount++;
}
Expand Down
15 changes: 13 additions & 2 deletions packages/vite-plugin-cloudflare/src/miniflare-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading