diff --git a/.changeset/per-directory-auth.md b/.changeset/per-directory-auth.md new file mode 100644 index 000000000000..00fe51bd2749 --- /dev/null +++ b/.changeset/per-directory-auth.md @@ -0,0 +1,56 @@ +--- +"wrangler": minor +"@cloudflare/workers-utils": minor +--- + +Add per-project authentication with `wrangler login --project` + +You can now store authentication tokens locally in your project directory instead of globally. This makes it easy to work with multiple Cloudflare accounts in different projects: + +```bash +wrangler login --project +``` + +Authentication will be stored in `.wrangler/config/default.toml` in your project directory and automatically detected by all Wrangler commands. + +**Features:** + +- **`--project` flag**: Use `wrangler login --project` to store OAuth tokens in the local `.wrangler` directory +- **Auto-detection**: Once logged in locally, all Wrangler commands automatically use the local authentication +- **`WRANGLER_HOME` environment variable**: Customize the global config directory location +- **`WRANGLER_AUTH_TYPE=global` environment variable**: Force all commands to use global auth instead of local + - Example: `WRANGLER_AUTH_TYPE=global wrangler kv namespace list` + - Useful when you have local auth but need to temporarily use global auth +- **Priority**: Environment variables (API tokens) > `WRANGLER_AUTH_TYPE=global` > Local auth > Global auth +- **`wrangler whoami`**: Shows whether you're using local or global authentication +- **`wrangler logout --project`**: Logout from local authentication + +**Aliases:** `--directory` and `--local` work as aliases for `--project`. + +**Example workflow:** + +```bash +# Login to global auth (default behavior) +wrangler login + +# In project A, login with a different account +cd ~/project-a +wrangler login --project + +# In project B, login with yet another account +cd ~/project-b +wrangler login --project + +# Now each project automatically uses its own auth +cd ~/project-a +wrangler whoami # Shows project A's account + +cd ~/project-b +wrangler whoami # Shows project B's account + +# Force using global auth in project A +cd ~/project-a +WRANGLER_AUTH_TYPE=global wrangler whoami # Shows global account +``` + +This feature is particularly useful when working on multiple projects that need different Cloudflare accounts, or in team environments where each developer uses their own account. diff --git a/packages/create-cloudflare/src/helpers/global-wrangler-config-path.ts b/packages/create-cloudflare/src/helpers/global-wrangler-config-path.ts index 2474f394c506..6d2c83a751ca 100644 --- a/packages/create-cloudflare/src/helpers/global-wrangler-config-path.ts +++ b/packages/create-cloudflare/src/helpers/global-wrangler-config-path.ts @@ -13,8 +13,22 @@ function isDirectory(configPath: string) { } } -export function getGlobalWranglerConfigPath() { - //TODO: We should implement a custom path --global-config and/or the WRANGLER_HOME type environment variable +/** + * Get the global Wrangler configuration directory path. + * Priority order: + * 1. WRANGLER_HOME environment variable (if set) + * 2. ~/.wrangler/ (legacy, if exists) + * 3. XDG-compliant path (default) + * + * @returns The path to the global Wrangler configuration directory + */ +export function getGlobalWranglerConfigPath(): string { + // Check for WRANGLER_HOME environment variable first + const wranglerHome = process.env.WRANGLER_HOME; + if (wranglerHome) { + return wranglerHome; + } + const configDir = xdgAppPaths(".wrangler").config(); // New XDG compliant config path const legacyConfigDir = nodePath.join(os.homedir(), ".wrangler"); // Legacy config in user's home directory @@ -25,3 +39,83 @@ export function getGlobalWranglerConfigPath() { return configDir; } } + +/** + * Find the project root directory by searching upward for project markers. + * Looks for: wrangler.toml, wrangler.json, wrangler.jsonc, package.json, or .git directory + * + * @param startDir The directory to start searching from (defaults to current working directory) + * @returns The project root directory path, or undefined if not found + */ +export function findProjectRoot( + startDir: string = process.cwd(), +): string | undefined { + const projectMarkers = [ + "wrangler.toml", + "wrangler.json", + "wrangler.jsonc", + "package.json", + ".git", + ]; + + let currentDir = nodePath.resolve(startDir); + const rootDir = nodePath.parse(currentDir).root; + + while (currentDir !== rootDir) { + // Check if any project marker exists in current directory + for (const marker of projectMarkers) { + const markerPath = nodePath.join(currentDir, marker); + if (fs.existsSync(markerPath)) { + return currentDir; + } + } + + // Move up one directory + const parentDir = nodePath.dirname(currentDir); + if (parentDir === currentDir) { + break; // Reached the root + } + currentDir = parentDir; + } + + return undefined; +} + +/** + * Get the local (project-specific) Wrangler configuration directory path. + * Searches for a .wrangler directory in the current directory or project root. + * + * @param projectRoot Optional project root directory (will be auto-detected if not provided) + * @returns The path to the local .wrangler directory, or undefined if not found + */ +export function getLocalWranglerConfigPath( + projectRoot?: string, +): string | undefined { + // Get the global wrangler path to compare against + const globalWranglerPath = nodePath.resolve(getGlobalWranglerConfigPath()); + + // First, check current directory + const cwdWrangler = nodePath.join(process.cwd(), ".wrangler"); + if (isDirectory(cwdWrangler)) { + // Ensure it's not the same as the global config path + if (nodePath.resolve(cwdWrangler) !== globalWranglerPath) { + return cwdWrangler; + } + } + + // If not provided, find the project root + const root = projectRoot ?? findProjectRoot(); + if (!root) { + return undefined; + } + + const localWranglerPath = nodePath.join(root, ".wrangler"); + if (isDirectory(localWranglerPath)) { + // Ensure it's not the same as the global config path + if (nodePath.resolve(localWranglerPath) !== globalWranglerPath) { + return localWranglerPath; + } + } + + return undefined; +} diff --git a/packages/miniflare/src/shared/wrangler.ts b/packages/miniflare/src/shared/wrangler.ts index a88e2181ee99..04c1f655e107 100644 --- a/packages/miniflare/src/shared/wrangler.ts +++ b/packages/miniflare/src/shared/wrangler.ts @@ -12,8 +12,22 @@ function isDirectory(configPath: string) { } } -export function getGlobalWranglerConfigPath() { - //TODO: We should implement a custom path --global-config and/or the WRANGLER_HOME type environment variable +/** + * Get the global Wrangler configuration directory path. + * Priority order: + * 1. WRANGLER_HOME environment variable (if set) + * 2. ~/.wrangler/ (legacy, if exists) + * 3. XDG-compliant path (default) + * + * @returns The path to the global Wrangler configuration directory + */ +export function getGlobalWranglerConfigPath(): string { + // Check for WRANGLER_HOME environment variable first + const wranglerHome = process.env.WRANGLER_HOME; + if (wranglerHome) { + return wranglerHome; + } + const configDir = xdgAppPaths(".wrangler").config(); // New XDG compliant config path const legacyConfigDir = path.join(os.homedir(), ".wrangler"); // Legacy config in user's home directory @@ -25,6 +39,86 @@ export function getGlobalWranglerConfigPath() { } } +/** + * Find the project root directory by searching upward for project markers. + * Looks for: wrangler.toml, wrangler.json, wrangler.jsonc, package.json, or .git directory + * + * @param startDir The directory to start searching from (defaults to current working directory) + * @returns The project root directory path, or undefined if not found + */ +export function findProjectRoot( + startDir: string = process.cwd() +): string | undefined { + const projectMarkers = [ + "wrangler.toml", + "wrangler.json", + "wrangler.jsonc", + "package.json", + ".git", + ]; + + let currentDir = path.resolve(startDir); + const rootDir = path.parse(currentDir).root; + + while (currentDir !== rootDir) { + // Check if any project marker exists in current directory + for (const marker of projectMarkers) { + const markerPath = path.join(currentDir, marker); + if (fs.existsSync(markerPath)) { + return currentDir; + } + } + + // Move up one directory + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; // Reached the root + } + currentDir = parentDir; + } + + return undefined; +} + +/** + * Get the local (project-specific) Wrangler configuration directory path. + * Searches for a .wrangler directory in the current directory or project root. + * + * @param projectRoot Optional project root directory (will be auto-detected if not provided) + * @returns The path to the local .wrangler directory, or undefined if not found + */ +export function getLocalWranglerConfigPath( + projectRoot?: string +): string | undefined { + // Get the global wrangler path to compare against + const globalWranglerPath = path.resolve(getGlobalWranglerConfigPath()); + + // First, check current directory + const cwdWrangler = path.join(process.cwd(), ".wrangler"); + if (isDirectory(cwdWrangler)) { + // Ensure it's not the same as the global config path + if (path.resolve(cwdWrangler) !== globalWranglerPath) { + return cwdWrangler; + } + } + + // If not provided, find the project root + const root = projectRoot ?? findProjectRoot(); + if (!root) { + return undefined; + } + + const localWranglerPath = path.join(root, ".wrangler"); + if (isDirectory(localWranglerPath)) { + // Ensure it's not the same as the global config path + if (path.resolve(localWranglerPath) !== globalWranglerPath) { + return localWranglerPath; + } + } + + return undefined; +} + export function getGlobalWranglerCachePath() { return xdgAppPaths(".wrangler").cache(); } diff --git a/packages/workers-utils/src/environment-variables/factory.ts b/packages/workers-utils/src/environment-variables/factory.ts index 4d1d3cb7d14c..5889280cb10c 100644 --- a/packages/workers-utils/src/environment-variables/factory.ts +++ b/packages/workers-utils/src/environment-variables/factory.ts @@ -21,6 +21,10 @@ type VariableNames = | "CLOUDFLARE_COMPLIANCE_REGION" /** API token for R2 SQL service. */ | "WRANGLER_R2_SQL_AUTH_TOKEN" + /** Custom directory for global Wrangler configuration and authentication. Overrides the default ~/.wrangler/ location. */ + | "WRANGLER_HOME" + /** Force authentication type. Set to "global" to use global auth even when local project auth exists. */ + | "WRANGLER_AUTH_TYPE" // ## Development & Local Testing diff --git a/packages/workers-utils/src/global-wrangler-config-path.ts b/packages/workers-utils/src/global-wrangler-config-path.ts index 1b01f485ec84..d443681f0ecb 100644 --- a/packages/workers-utils/src/global-wrangler-config-path.ts +++ b/packages/workers-utils/src/global-wrangler-config-path.ts @@ -1,10 +1,25 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import xdgAppPaths from "xdg-app-paths"; import { isDirectory } from "./fs-helpers"; -export function getGlobalWranglerConfigPath() { - //TODO: We should implement a custom path --global-config and/or the WRANGLER_HOME type environment variable +/** + * Get the global Wrangler configuration directory path. + * Priority order: + * 1. WRANGLER_HOME environment variable (if set) + * 2. ~/.wrangler/ (legacy, if exists) + * 3. XDG-compliant path (default) + * + * @returns The path to the global Wrangler configuration directory + */ +export function getGlobalWranglerConfigPath(): string { + // Check for WRANGLER_HOME environment variable first + const wranglerHome = process.env.WRANGLER_HOME; + if (wranglerHome) { + return wranglerHome; + } + const configDir = xdgAppPaths(".wrangler").config(); // New XDG compliant config path const legacyConfigDir = path.join(os.homedir(), ".wrangler"); // Legacy config in user's home directory @@ -15,3 +30,83 @@ export function getGlobalWranglerConfigPath() { return configDir; } } + +/** + * Find the project root directory by searching upward for project markers. + * Looks for: wrangler.toml, wrangler.json, wrangler.jsonc, package.json, or .git directory + * + * @param startDir The directory to start searching from (defaults to current working directory) + * @returns The project root directory path, or undefined if not found + */ +export function findProjectRoot( + startDir: string = process.cwd() +): string | undefined { + const projectMarkers = [ + "wrangler.toml", + "wrangler.json", + "wrangler.jsonc", + "package.json", + ".git", + ]; + + let currentDir = path.resolve(startDir); + const rootDir = path.parse(currentDir).root; + + while (currentDir !== rootDir) { + // Check if any project marker exists in current directory + for (const marker of projectMarkers) { + const markerPath = path.join(currentDir, marker); + if (fs.existsSync(markerPath)) { + return currentDir; + } + } + + // Move up one directory + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; // Reached the root + } + currentDir = parentDir; + } + + return undefined; +} + +/** + * Get the local (project-specific) Wrangler configuration directory path. + * Searches for a .wrangler directory in the current directory or project root. + * + * @param projectRoot Optional project root directory (will be auto-detected if not provided) + * @returns The path to the local .wrangler directory, or undefined if not found + */ +export function getLocalWranglerConfigPath( + projectRoot?: string +): string | undefined { + // Get the global wrangler path to compare against + const globalWranglerPath = path.resolve(getGlobalWranglerConfigPath()); + + // First, check current directory + const cwdWrangler = path.join(process.cwd(), ".wrangler"); + if (isDirectory(cwdWrangler)) { + // Ensure it's not the same as the global config path + if (path.resolve(cwdWrangler) !== globalWranglerPath) { + return cwdWrangler; + } + } + + // If not provided, find the project root + const root = projectRoot ?? findProjectRoot(); + if (!root) { + return undefined; + } + + const localWranglerPath = path.join(root, ".wrangler"); + if (isDirectory(localWranglerPath)) { + // Ensure it's not the same as the global config path + if (path.resolve(localWranglerPath) !== globalWranglerPath) { + return localWranglerPath; + } + } + + return undefined; +} diff --git a/packages/workers-utils/src/index.ts b/packages/workers-utils/src/index.ts index c23024b687b8..64a623f703de 100644 --- a/packages/workers-utils/src/index.ts +++ b/packages/workers-utils/src/index.ts @@ -80,7 +80,11 @@ export { export * from "./environment-variables/misc-variables"; -export { getGlobalWranglerConfigPath } from "./global-wrangler-config-path"; +export { + findProjectRoot, + getGlobalWranglerConfigPath, + getLocalWranglerConfigPath, +} from "./global-wrangler-config-path"; export { getLocalWorkerdCompatibilityDate, diff --git a/packages/wrangler/src/__tests__/whoami.test.ts b/packages/wrangler/src/__tests__/whoami.test.ts index 6e8587a4ac40..81bef0336aa1 100644 --- a/packages/wrangler/src/__tests__/whoami.test.ts +++ b/packages/wrangler/src/__tests__/whoami.test.ts @@ -310,6 +310,7 @@ describe("whoami", () => { ────────────────── Getting User settings... 👋 You are logged in with an OAuth Token, associated with the email user@example.com. + 🔐 Auth source: global (/home/.config/.wrangler/config/default.toml) ┌─┬─┐ │ Account Name │ Account ID │ ├─┼─┤ @@ -375,6 +376,7 @@ describe("whoami", () => { ────────────────── Getting User settings... 👋 You are logged in with an OAuth Token, associated with the email (redacted). + 🔐 Auth source: global (/home/.config/.wrangler/config/default.toml) ┌─┬─┐ │ Account Name │ Account ID │ ├─┼─┤ @@ -414,6 +416,8 @@ describe("whoami", () => { 🎢 Membership roles in "(redacted)": Contact account super admin to change your permissions. - Test role" `); + expect(std.warn).toMatchInlineSnapshot(`""`); + expect(std.err).toMatchInlineSnapshot(`""`); }); it("should output JSON with user info when --json flag is used and authenticated", async ({ @@ -470,7 +474,7 @@ describe("whoami", () => { "*/memberships", () => HttpResponse.json( - createFetchResult(undefined, false, [ + createFetchResult([], false, [ { code: 10000, message: "Authentication error" }, ]) ), @@ -484,6 +488,7 @@ describe("whoami", () => { ────────────────── Getting User settings... 👋 You are logged in with an OAuth Token, associated with the email user@example.com. + 🔐 Auth source: global (/home/.config/.wrangler/config/default.toml) ┌─┬─┐ │ Account Name │ Account ID │ ├─┼─┤ diff --git a/packages/wrangler/src/user/auth-variables.ts b/packages/wrangler/src/user/auth-variables.ts index 218d452febde..4ee83ece2387 100644 --- a/packages/wrangler/src/user/auth-variables.ts +++ b/packages/wrangler/src/user/auth-variables.ts @@ -27,6 +27,20 @@ export const getCloudflareGlobalAuthEmailFromEnv = deprecatedName: "CF_EMAIL", }); +/** + * `WRANGLER_HOME` overrides the directory where Wrangler stores + * global configuration and authentication data. + * + * By default, Wrangler uses: + * - ~/.wrangler/ (legacy) + * - Or XDG-compliant paths (e.g., ~/Library/Application Support/.wrangler on macOS) + * + * Set this to use a custom directory for global config storage. + */ +export const getWranglerHomeFromEnv = getEnvironmentVariableFactory({ + variableName: "WRANGLER_HOME", +}); + /** * `WRANGLER_CLIENT_ID` is a UUID that is used to identify Wrangler * to the Cloudflare APIs. diff --git a/packages/wrangler/src/user/commands.ts b/packages/wrangler/src/user/commands.ts index e3cd9d593785..769103739eaa 100644 --- a/packages/wrangler/src/user/commands.ts +++ b/packages/wrangler/src/user/commands.ts @@ -46,6 +46,12 @@ export const loginCommand = createCommand({ type: "string", requiresArg: true, }, + project: { + type: "boolean", + describe: + "Store authentication in local .wrangler directory for this project", + alias: ["directory", "local"], + }, "callback-host": { describe: "Use the ip or host address for the temporary login callback server.", @@ -81,6 +87,7 @@ export const loginCommand = createCommand({ browser: args.browser, callbackHost: args.callbackHost, callbackPort: args.callbackPort, + project: args.project, }); return; } @@ -88,6 +95,7 @@ export const loginCommand = createCommand({ browser: args.browser, callbackHost: args.callbackHost, callbackPort: args.callbackPort, + project: args.project, }); metrics.sendMetricsEvent("login user", { sendMetrics: config.send_metrics, @@ -110,8 +118,15 @@ export const logoutCommand = createCommand({ printConfigWarnings: false, provideConfig: false, }, - async handler() { - await logout(); + args: { + project: { + type: "boolean", + describe: "Logout from local .wrangler directory authentication", + alias: ["directory", "local"], + }, + }, + async handler(args) { + await logout({ project: args.project }); try { // If the config file is invalid then we default to not sending metrics. // TODO: Clean this up as part of a general config refactor. diff --git a/packages/wrangler/src/user/user.ts b/packages/wrangler/src/user/user.ts index 9e74f5b64683..9ee8aa8a5608 100644 --- a/packages/wrangler/src/user/user.ts +++ b/packages/wrangler/src/user/user.ts @@ -207,16 +207,18 @@ import assert from "node:assert"; import { webcrypto as crypto } from "node:crypto"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, 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, + findProjectRoot, getCloudflareApiEnvironmentFromEnv, getCloudflareComplianceRegion, getGlobalWranglerConfigPath, + getLocalWranglerConfigPath, parseTOML, readFileSync, UserError, @@ -306,6 +308,8 @@ interface State extends AuthTokens { stateQueryParam?: string; scopes?: Scope[]; account?: Account; + /** Track whether auth came from local or global config for refresh operations */ + authSource?: "local" | "global"; } /** @@ -398,34 +402,61 @@ export function validateScopeKeys( return scopes.every((scope) => scope in DefaultScopes); } +const initialAuth = getAuthTokens(); let localState: State = { - ...getAuthTokens(), + ...initialAuth.tokens, + authSource: initialAuth.authSource, }; /** - * Compute the current auth tokens. + * Compute the current auth tokens and determine auth source. */ -function getAuthTokens(config?: UserAuthConfig): AuthTokens | undefined { +function getAuthTokens(config?: UserAuthConfig): { + tokens: AuthTokens | undefined; + authSource?: "local" | "global"; +} { // get refreshToken/accessToken from fs if exists try { // if the environment variable is available, we don't need to do anything here if (getAuthFromEnv()) { - return; + return { tokens: undefined }; + } + + // Determine the auth source when reading from file + let authSource: "local" | "global" | undefined; + let authConfig: UserAuthConfig; + + if (config) { + authConfig = config; + } else { + const configPath = getAuthConfigFilePath(); + authConfig = readAuthConfigFile(); + // Detect if the config path is local or global + const globalConfigPath = path.resolve(getGlobalWranglerConfigPath()); + const resolvedConfigPath = path.resolve(path.dirname(configPath)); + const projectRoot = findProjectRoot(); + const isLocal = + resolvedConfigPath !== globalConfigPath && + projectRoot && + configPath.includes(path.join(projectRoot, ".wrangler")); + authSource = isLocal ? "local" : "global"; } - // otherwise try loading from the user auth config file. const { oauth_token, refresh_token, expiration_time, scopes, api_token } = - config || readAuthConfigFile(); + authConfig; 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", + tokens: { + 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[], }, - refreshToken: { value: refresh_token ?? "" }, - scopes: scopes as Scope[], + authSource, }; } else if (api_token) { logger.warn( @@ -434,11 +465,12 @@ function getAuthTokens(config?: UserAuthConfig): AuthTokens | undefined { "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 { apiToken: api_token }; + return { tokens: { apiToken: api_token }, authSource }; } } catch { - return undefined; + return { tokens: undefined }; } + return { tokens: undefined }; } /** @@ -453,11 +485,19 @@ export function reinitialiseAuthTokens(): void; * Reinitialise auth state from an in-memory config, skipping * over the part where we write a file and then read it back into memory */ -export function reinitialiseAuthTokens(config: UserAuthConfig): void; - -export function reinitialiseAuthTokens(config?: UserAuthConfig): void { +export function reinitialiseAuthTokens( + config: UserAuthConfig, + authSource?: "local" | "global" +): void; + +export function reinitialiseAuthTokens( + config?: UserAuthConfig, + authSource?: "local" | "global" +): void { + const { tokens, authSource: detectedSource } = getAuthTokens(config); localState = { - ...getAuthTokens(config), + ...tokens, + authSource: authSource ?? detectedSource, }; } @@ -904,19 +944,71 @@ async function generatePKCECodes(): Promise { return { codeChallenge, codeVerifier }; } -export function getAuthConfigFilePath() { +export interface GetAuthConfigFilePathOptions { + /** Explicitly force local auth path */ + useLocal?: boolean; + /** Explicitly force global auth path */ + useGlobal?: boolean; +} + +export function getAuthConfigFilePath( + options?: GetAuthConfigFilePathOptions +): string { const environment = getCloudflareApiEnvironmentFromEnv(); - const filePath = `${USER_AUTH_CONFIG_PATH}/${environment === "production" ? "default.toml" : `${environment}.toml`}`; + const fileName = + environment === "production" ? "default.toml" : `${environment}.toml`; + const configSubPath = `${USER_AUTH_CONFIG_PATH}/${fileName}`; // "config/default.toml" + + // Check for WRANGLER_AUTH_TYPE environment variable to force global auth + const authType = process.env.WRANGLER_AUTH_TYPE; + if (authType === "global" && !options?.useLocal) { + return path.join(getGlobalWranglerConfigPath(), configSubPath); + } + + // Explicit flags take priority + if (options?.useGlobal) { + return path.join(getGlobalWranglerConfigPath(), configSubPath); + } - return path.join(getGlobalWranglerConfigPath(), filePath); + if (options?.useLocal) { + let localPath = getLocalWranglerConfigPath(); + if (!localPath) { + // .wrangler directory doesn't exist yet, need to create it + const projectRoot = findProjectRoot(); + if (!projectRoot) { + throw new UserError( + "Cannot use --directory: no project directory found.\n" + + "Run this command in a directory with wrangler.toml, package.json, or a .git directory." + ); + } + // Create the .wrangler directory in the project root + localPath = path.join(projectRoot, ".wrangler"); + } + return path.join(localPath, configSubPath); + } + + // Auto-detect: check for local config first + const localPath = getLocalWranglerConfigPath(); + if (localPath) { + const localConfigPath = path.join(localPath, configSubPath); + if (existsSync(localConfigPath)) { + return localConfigPath; + } + } + + // Fall back to global + return path.join(getGlobalWranglerConfigPath(), configSubPath); } /** * Writes a a wrangler config file (auth credentials) to disk, * and updates the user auth state with the new credentials. */ -export function writeAuthConfigFile(config: UserAuthConfig) { - const configPath = getAuthConfigFilePath(); +export function writeAuthConfigFile( + config: UserAuthConfig, + options?: GetAuthConfigFilePathOptions +) { + const configPath = getAuthConfigFilePath(options); mkdirSync(path.dirname(configPath), { recursive: true, @@ -925,11 +1017,30 @@ export function writeAuthConfigFile(config: UserAuthConfig) { encoding: "utf-8", }); - reinitialiseAuthTokens(); + // Track whether this is local or global auth + const globalConfigPath = path.resolve(getGlobalWranglerConfigPath()); + const resolvedConfigPath = path.resolve(path.dirname(configPath)); + const projectRoot = findProjectRoot(); + const isLocal = + resolvedConfigPath !== globalConfigPath && + (configPath.includes(path.join(process.cwd(), ".wrangler")) || + (projectRoot !== undefined && + configPath.includes(path.join(projectRoot, ".wrangler")))); + + // Reinitialise without passing config so warnings show correct file path + const { tokens } = getAuthTokens(); + localState = { + ...tokens, + authSource: isLocal ? "local" : "global", + }; } -export function readAuthConfigFile(): UserAuthConfig { - return parseTOML(readFileSync(getAuthConfigFilePath())) as UserAuthConfig; +export function readAuthConfigFile( + options?: GetAuthConfigFilePathOptions +): UserAuthConfig { + return parseTOML( + readFileSync(getAuthConfigFilePath(options)) + ) as UserAuthConfig; } type LoginProps = { @@ -937,6 +1048,7 @@ type LoginProps = { browser: boolean; callbackHost: string; callbackPort: number; + project?: boolean; }; export async function loginOrRefreshIfRequired( @@ -1149,14 +1261,26 @@ export async function login( callbackPort: props.callbackPort, }); - writeAuthConfigFile({ - oauth_token: oauth.token?.value ?? "", - expiration_time: oauth.token?.expiry, - refresh_token: oauth.refreshToken?.value, - scopes: oauth.scopes, - }); + const authOptions = props.project ? { useLocal: true } : undefined; + + writeAuthConfigFile( + { + oauth_token: oauth.token?.value ?? "", + expiration_time: oauth.token?.expiry, + refresh_token: oauth.refreshToken?.value, + scopes: oauth.scopes, + }, + authOptions + ); + + const configPath = getAuthConfigFilePath(authOptions); + const isLocal = props.project; logger.log(`Successfully logged in.`); + if (isLocal) { + const relativePath = path.relative(process.cwd(), configPath); + logger.log(`🔐 Authentication stored in: ${relativePath}`); + } purgeConfigCaches(); @@ -1182,19 +1306,29 @@ async function refreshToken(): Promise { refreshToken: { value: refresh_token } = {}, scopes, } = await exchangeRefreshTokenForAccessToken(); - writeAuthConfigFile({ - oauth_token, - expiration_time, - refresh_token, - scopes, - }); + + // Write to the same location where auth was originally read from + const authOptions = + localState.authSource === "local" + ? { useLocal: true } + : { useGlobal: true }; + + writeAuthConfigFile( + { + oauth_token, + expiration_time, + refresh_token, + scopes, + }, + authOptions + ); return true; } catch { return false; } } -export async function logout(): Promise { +export async function logout(options?: { project?: boolean }): Promise { const authFromEnv = getAuthFromEnv(); if (authFromEnv) { // Auth from env overrides any login details, so we cannot log out. @@ -1205,8 +1339,31 @@ export async function logout(): Promise { return; } - if (!localState.accessToken) { - if (!localState.refreshToken) { + // Determine which auth file to remove + const authOptions = options?.project ? { useLocal: true } : undefined; + const configPath = getAuthConfigFilePath(authOptions); + + // Check if the config file exists + if (!existsSync(configPath)) { + logger.log("Not logged in, exiting..."); + return; + } + + // Read tokens from the target auth file (not from localState) + // This ensures we revoke the correct tokens when using --project + let targetConfig: UserAuthConfig; + try { + targetConfig = parseTOML(readFileSync(configPath)) as UserAuthConfig; + } catch { + logger.log("Error reading auth config file, exiting..."); + return; + } + + const targetAccessToken = targetConfig.oauth_token; + const targetRefreshToken = targetConfig.refresh_token; + + if (!targetAccessToken) { + if (!targetRefreshToken) { logger.log("Not logged in, exiting..."); return; } @@ -1214,7 +1371,7 @@ export async function logout(): Promise { const body = `client_id=${encodeURIComponent(getClientIdFromEnv())}&` + `token_type_hint=refresh_token&` + - `token=${encodeURIComponent(localState.refreshToken?.value || "")}`; + `token=${encodeURIComponent(targetRefreshToken)}`; const response = await fetch(getRevokeUrlFromEnv(), { method: "POST", @@ -1231,7 +1388,7 @@ export async function logout(): Promise { const body = `client_id=${encodeURIComponent(getClientIdFromEnv())}&` + `token_type_hint=refresh_token&` + - `token=${encodeURIComponent(localState.refreshToken?.value || "")}`; + `token=${encodeURIComponent(targetRefreshToken || "")}`; const response = await fetch(getRevokeUrlFromEnv(), { method: "POST", @@ -1241,7 +1398,7 @@ export async function logout(): Promise { }, }); await response.text(); // blank text? would be nice if it was something meaningful - rmSync(getAuthConfigFilePath()); + rmSync(configPath); logger.log(`Successfully logged out.`); } diff --git a/packages/wrangler/src/user/whoami.ts b/packages/wrangler/src/user/whoami.ts index 1cbe658bddc4..3927ad68a56d 100644 --- a/packages/wrangler/src/user/whoami.ts +++ b/packages/wrangler/src/user/whoami.ts @@ -1,6 +1,9 @@ +import path from "node:path"; import { - createFatalError, + createFatalError, + findProjectRoot, getCloudflareComplianceRegion, + getGlobalWranglerConfigPath, } from "@cloudflare/workers-utils"; import chalk from "chalk"; import { fetchPagedListResult, fetchResult } from "../cfetch"; @@ -9,7 +12,13 @@ import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { formatMessage } from "../utils/format-message"; import { fetchMembershipRoles } from "./membership"; -import { DefaultScopeKeys, getAPIToken, getAuthFromEnv, getScopes } from "."; +import { + DefaultScopeKeys, + getAPIToken, + getAuthConfigFilePath, + getAuthFromEnv, + getScopes, +} from "."; import type { ApiCredentials, Scope } from "."; import type { ComplianceConfig } from "@cloudflare/workers-utils"; @@ -72,6 +81,8 @@ export async function whoami( logger.log( "ℹ️ The API Token is read from the CLOUDFLARE_API_TOKEN environment variable." ); + } else if (user.authType === "OAuth Token") { + printAuthSource(); } printComplianceRegion(complianceConfig); printAccountList(user); @@ -80,6 +91,28 @@ export async function whoami( await printMembershipInfo(complianceConfig, user, accountFilter); } +function printAuthSource() { + try { + const configPath = getAuthConfigFilePath(); + const globalConfigPath = path.resolve(getGlobalWranglerConfigPath()); + const resolvedConfigPath = path.resolve(path.dirname(configPath)); + const projectRoot = findProjectRoot(); + const isLocal = + resolvedConfigPath !== globalConfigPath && + projectRoot && + configPath.includes(path.join(projectRoot, ".wrangler")); + + if (isLocal) { + const relativePath = path.relative(process.cwd(), configPath); + logger.log(`🔐 Auth source: local (${chalk.blue(relativePath)})`); + } else { + logger.log(`🔐 Auth source: global (${chalk.blue(configPath)})`); + } + } catch { + // If we can't determine auth source, don't show anything + } +} + function printComplianceRegion(complianceConfig: ComplianceConfig) { const complianceRegion = getCloudflareComplianceRegion(complianceConfig); if (complianceRegion !== "public") { diff --git a/turbo.json b/turbo.json index dc3e31265301..16084ef43720 100644 --- a/turbo.json +++ b/turbo.json @@ -14,7 +14,9 @@ "TEST_REPORT_PATH", "VSCODE_INSPECTOR_OPTIONS", "WRANGLER_API_ENVIRONMENT", + "WRANGLER_AUTH_TYPE", "WRANGLER_DOCKER_HOST", + "WRANGLER_HOME", "WRANGLER_LOG_PATH", "WRANGLER_LOG" ],