diff --git a/.changeset/thick-meals-itch.md b/.changeset/thick-meals-itch.md new file mode 100644 index 000000000000..c649cdf2aef7 --- /dev/null +++ b/.changeset/thick-meals-itch.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +feat: add support for CLOUDFLARE_API_KEY + CLOUDFLARE_EMAIL to authorise + +This adds support for using the CLOUDFLARE_API_KEY + CLOUDFLARE_EMAIL env vars for authorising a user. This also adds support for CF_API_KEY + CF_EMAIL from wrangler 1, with a deprecation warning. diff --git a/packages/wrangler/src/__tests__/whoami.test.tsx b/packages/wrangler/src/__tests__/whoami.test.tsx index c3abe201f105..59b7217f6883 100644 --- a/packages/wrangler/src/__tests__/whoami.test.tsx +++ b/packages/wrangler/src/__tests__/whoami.test.tsx @@ -51,7 +51,7 @@ describe("getUserInfo()", () => { const userInfo = await getUserInfo(); expect(userInfo).toEqual({ - authType: "API", + authType: "API Token", apiToken: "123456789", email: "user@example.com", accounts: [ @@ -62,6 +62,67 @@ describe("getUserInfo()", () => { }); }); + it("should say it's using a Global API Key when one is set", async () => { + process.env = { + CLOUDFLARE_API_KEY: "123456789", + CLOUDFLARE_EMAIL: "user@example.com", + }; + setMockResponse("/accounts", () => { + return [ + { name: "Account One", id: "account-1" }, + { name: "Account Two", id: "account-2" }, + { name: "Account Three", id: "account-3" }, + ]; + }); + + const userInfo = await getUserInfo(); + expect(userInfo).toEqual({ + authType: "Global API Key", + apiToken: "123456789", + email: "user@example.com", + accounts: [ + { name: "Account One", id: "account-1" }, + { name: "Account Two", id: "account-2" }, + { name: "Account Three", id: "account-3" }, + ], + }); + }); + + it("should use a Global API Key in preference to an API token", async () => { + process.env = { + CLOUDFLARE_API_KEY: "123456789", + CLOUDFLARE_EMAIL: "user@example.com", + CLOUDFLARE_API_TOKEN: "123456789", + }; + setMockResponse("/accounts", () => { + return [ + { name: "Account One", id: "account-1" }, + { name: "Account Two", id: "account-2" }, + { name: "Account Three", id: "account-3" }, + ]; + }); + + const userInfo = await getUserInfo(); + expect(userInfo).toEqual({ + authType: "Global API Key", + apiToken: "123456789", + email: "user@example.com", + accounts: [ + { name: "Account One", id: "account-1" }, + { name: "Account Two", id: "account-2" }, + { name: "Account Three", id: "account-3" }, + ], + }); + }); + + it("should return undefined only a Global API Key, but not Email, is set", async () => { + process.env = { + CLOUDFLARE_API_KEY: "123456789", + }; + const userInfo = await getUserInfo(); + expect(userInfo).toEqual(undefined); + }); + it("should return the user's email and accounts if authenticated via config token", async () => { writeAuthConfigFile({ oauth_token: "some-oauth-token" }); @@ -79,7 +140,7 @@ describe("getUserInfo()", () => { const userInfo = await getUserInfo(); expect(userInfo).toEqual({ - authType: "OAuth", + authType: "OAuth Token", apiToken: "some-oauth-token", email: "user@example.com", accounts: [ @@ -116,7 +177,7 @@ describe("WhoAmI component", () => { it("should display the user's email and accounts", async () => { const user: UserInfo = { - authType: "OAuth", + authType: "OAuth Token", apiToken: "some-oauth-token", email: "user@example.com", accounts: [ diff --git a/packages/wrangler/src/cfetch/internal.ts b/packages/wrangler/src/cfetch/internal.ts index 0221d392f1ed..5bf8d01d1e28 100644 --- a/packages/wrangler/src/cfetch/internal.ts +++ b/packages/wrangler/src/cfetch/internal.ts @@ -4,6 +4,7 @@ import { version as wranglerVersion } from "../../package.json"; import { getEnvironmentVariableFactory } from "../environment-variables"; import { ParseError, parseJSON } from "../parse"; import { loginOrRefreshIfRequired, requireApiToken } from "../user"; +import type { ApiCredentials } from "../user"; import type { URLSearchParams } from "node:url"; import type { RequestInit, HeadersInit } from "undici"; @@ -97,10 +98,15 @@ async function requireLoggedIn(): Promise { function addAuthorizationHeaderIfUnspecified( headers: Record, - apiToken: string + auth: ApiCredentials ): void { if (!("Authorization" in headers)) { - headers["Authorization"] = `Bearer ${apiToken}`; + if ("apiToken" in auth) { + headers["Authorization"] = `Bearer ${auth.apiToken}`; + } else { + headers["X-Auth-Key"] = auth.authKey; + headers["X-Auth-Email"] = auth.authEmail; + } } } @@ -125,8 +131,9 @@ export async function fetchKVGetValue( key: string ): Promise { await requireLoggedIn(); - const apiToken = requireApiToken(); - const headers = { Authorization: `Bearer ${apiToken}` }; + const auth = requireApiToken(); + const headers: Record = {}; + addAuthorizationHeaderIfUnspecified(headers, auth); const resource = `${getCloudflareAPIBaseURL()}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${key}`; const response = await fetch(resource, { method: "GET", diff --git a/packages/wrangler/src/user/env-vars.ts b/packages/wrangler/src/user/env-vars.ts index 2f9d62a53e85..fab932fea328 100644 --- a/packages/wrangler/src/user/env-vars.ts +++ b/packages/wrangler/src/user/env-vars.ts @@ -1,12 +1,41 @@ import { getEnvironmentVariableFactory } from "../environment-variables"; -/** - * Try to read the API token from the environment. - */ -export const getCloudflareAPITokenFromEnv = getEnvironmentVariableFactory({ +export type ApiCredentials = + | { + apiToken: string; + } + | { + authKey: string; + authEmail: string; + }; + +const getCloudflareAPITokenFromEnv = getEnvironmentVariableFactory({ variableName: "CLOUDFLARE_API_TOKEN", deprecatedName: "CF_API_TOKEN", }); +const getCloudflareGlobalAuthKeyFromEnv = getEnvironmentVariableFactory({ + variableName: "CLOUDFLARE_API_KEY", + deprecatedName: "CF_API_KEY", +}); +const getCloudflareGlobalAuthEmailFromEnv = getEnvironmentVariableFactory({ + variableName: "CLOUDFLARE_EMAIL", + deprecatedName: "CF_EMAIL", +}); + +/** + * Try to read an API token or Global Auth from the environment. + */ +export function getAuthFromEnv(): ApiCredentials | undefined { + const globalApiKey = getCloudflareGlobalAuthKeyFromEnv(); + const globalApiEmail = getCloudflareGlobalAuthEmailFromEnv(); + const apiToken = getCloudflareAPITokenFromEnv(); + + if (globalApiKey && globalApiEmail) { + return { authKey: globalApiKey, authEmail: globalApiEmail }; + } else if (apiToken) { + return { apiToken }; + } +} /** * Try to read the account ID from the environment. diff --git a/packages/wrangler/src/user/user.tsx b/packages/wrangler/src/user/user.tsx index 900fcf5b9e7e..cf9161d8fef8 100644 --- a/packages/wrangler/src/user/user.tsx +++ b/packages/wrangler/src/user/user.tsx @@ -207,7 +207,7 @@ import assert from "node:assert"; import { webcrypto as crypto } from "node:crypto"; -import { writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import http from "node:http"; import os from "node:os"; import path from "node:path"; @@ -224,9 +224,10 @@ import { logger } from "../logger"; import openInBrowser from "../open-in-browser"; import { parseTOML, readFileSync } from "../parse"; import { ChooseAccount, getAccountChoices } from "./choose-account"; -import { getCloudflareAPITokenFromEnv } from "./env-vars"; +import { getAuthFromEnv } from "./env-vars"; import { generateAuthUrl } from "./generate-auth-url"; import { generateRandomState } from "./generate-random-state"; +import type { ApiCredentials } from "./env-vars"; import type { ParsedUrlQuery } from "node:querystring"; /** @@ -335,24 +336,18 @@ const REVOKE_URL = "https://dash.cloudflare.com/oauth2/revoke"; */ const CALLBACK_URL = HostURL.parse("http://localhost:8976/oauth/callback").href; -let LocalState: State = getAuthTokens(); +let LocalState: State = { + ...getAuthTokens(), +}; /** * Compute the current auth tokens. */ -function getAuthTokens(config?: UserAuthConfig): AuthTokens { +function getAuthTokens(config?: UserAuthConfig): AuthTokens | undefined { // get refreshToken/accessToken from fs if exists try { - // if the environment variable is available, use that - const apiTokenFromEnv = getCloudflareAPITokenFromEnv(); - if (apiTokenFromEnv) { - return { - accessToken: { - value: apiTokenFromEnv, - expiry: "3021-12-31T23:59:59+00:00", - }, - }; - } + // if the environment variable is available, we don't need to do anything here + if (getAuthFromEnv()) return; // otherwise try loading from the user auth config file. const { oauth_token, refresh_token, expiration_time, api_token } = @@ -369,11 +364,9 @@ function getAuthTokens(config?: UserAuthConfig): AuthTokens { }; } else if (api_token) { return { apiToken: api_token }; - } else { - return {}; } } catch { - return {}; + return undefined; } } @@ -392,11 +385,13 @@ export function reinitialiseAuthTokens(): void; export function reinitialiseAuthTokens(config: UserAuthConfig): void; export function reinitialiseAuthTokens(config?: UserAuthConfig): void { - LocalState = getAuthTokens(config); + LocalState = { + ...getAuthTokens(config), + }; } -export function getAPIToken(): string | undefined { - if (LocalState.apiToken) { +export function getAPIToken(): ApiCredentials | undefined { + if ("apiToken" in LocalState) { logger.warn( "It looks like you have used Wrangler 1's `config` command to login with an API token.\n" + "This is no longer supported in the current version of Wrangler.\n" + @@ -404,19 +399,17 @@ export function getAPIToken(): string | undefined { ); } - const localAPIToken = getCloudflareAPITokenFromEnv(); + const localAPIToken = getAuthFromEnv(); + if (localAPIToken) return localAPIToken; - if ( - !process.stdout.isTTY && - !localAPIToken && - !LocalState.accessToken?.value - ) { + const storedAccessToken = LocalState.accessToken?.value; + if (storedAccessToken) return { apiToken: storedAccessToken }; + + if (!process.stdout.isTTY) { throw new Error( "In a non-interactive environment, it's necessary to set a CLOUDFLARE_API_TOKEN environment variable for wrangler to work. Please go to https://developers.cloudflare.com/api/tokens/create/ for instructions on how to create an api token, and assign its value to CLOUDFLARE_API_TOKEN." ); } - - return localAPIToken ?? LocalState.accessToken?.value; } interface AccessContext { @@ -1120,12 +1113,12 @@ export async function requireAuth(config: { /** * Throw an error if there is no API token available. */ -export function requireApiToken(): string { - const authToken = getAPIToken(); - if (!authToken) { +export function requireApiToken(): ApiCredentials { + const credentials = getAPIToken(); + if (!credentials) { throw new Error("No API token found."); } - return authToken; + return credentials; } function isInteractive(): boolean { diff --git a/packages/wrangler/src/whoami.tsx b/packages/wrangler/src/whoami.tsx index 62d710a54391..540691c07012 100644 --- a/packages/wrangler/src/whoami.tsx +++ b/packages/wrangler/src/whoami.tsx @@ -3,7 +3,7 @@ import Table from "ink-table"; import React from "react"; import { fetchListResult, fetchResult } from "./cfetch"; import { logger } from "./logger"; -import { getAPIToken, getCloudflareAPITokenFromEnv } from "./user"; +import { getAPIToken, getAuthFromEnv } from "./user"; export async function whoami() { logger.log("Getting User settings..."); @@ -25,8 +25,8 @@ export function WhoAmI({ user }: { user: UserInfo | undefined }) { function Email(props: { tokenType: string; email: string }) { return ( - 👋 You are logged in with an {props.tokenType} Token, associated with the - email '{props.email}'! + 👋 You are logged in with an {props.tokenType}, associated with the email + '{props.email}'! ); } @@ -48,15 +48,20 @@ export interface UserInfo { export async function getUserInfo(): Promise { const apiToken = getAPIToken(); - const apiTokenFromEnv = getCloudflareAPITokenFromEnv(); - return apiToken - ? { - apiToken, - authType: apiTokenFromEnv ? "API" : "OAuth", - email: await getEmail(), - accounts: await getAccounts(), - } - : undefined; + if (!apiToken) return; + + const usingEnvAuth = !!getAuthFromEnv(); + const usingGlobalAuthKey = "authKey" in apiToken; + return { + apiToken: usingGlobalAuthKey ? apiToken.authKey : apiToken.apiToken, + authType: usingGlobalAuthKey + ? "Global API Key" + : usingEnvAuth + ? "API Token" + : "OAuth Token", + email: await getEmail(), + accounts: await getAccounts(), + }; } async function getEmail(): Promise { diff --git a/packages/wrangler/src/worker.ts b/packages/wrangler/src/worker.ts index 170a44fc11a0..76e27eed1ff3 100644 --- a/packages/wrangler/src/worker.ts +++ b/packages/wrangler/src/worker.ts @@ -1,13 +1,15 @@ /** * A Cloudflare account. */ +import type { ApiCredentials } from "./user"; + export interface CfAccount { /** * An API token. * * @link https://api.cloudflare.com/#user-api-tokens-properties */ - apiToken: string; + apiToken: ApiCredentials; /** * An account ID. */