Skip to content

Commit

Permalink
Added Global API Key (X-Auth-Key) support to Wrangler (#1351)
Browse files Browse the repository at this point in the history
* Added Global API Key (X-Auth-Key) support to Wrangler

* rename to CLOUDFLARE_API_KEY / CLOUDFLARE_EMAIL

* fix a test's name

Co-authored-by: Glen Maddern <[email protected]>
Co-authored-by: Sunil Pai <[email protected]>
  • Loading branch information
3 people authored Jun 27, 2022
1 parent 8d68226 commit c770167
Show file tree
Hide file tree
Showing 7 changed files with 160 additions and 56 deletions.
7 changes: 7 additions & 0 deletions .changeset/thick-meals-itch.md
Original file line number Diff line number Diff line change
@@ -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.
67 changes: 64 additions & 3 deletions packages/wrangler/src/__tests__/whoami.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe("getUserInfo()", () => {

const userInfo = await getUserInfo();
expect(userInfo).toEqual({
authType: "API",
authType: "API Token",
apiToken: "123456789",
email: "[email protected]",
accounts: [
Expand All @@ -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: "[email protected]",
};
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: "[email protected]",
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: "[email protected]",
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: "[email protected]",
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" });

Expand All @@ -79,7 +140,7 @@ describe("getUserInfo()", () => {
const userInfo = await getUserInfo();

expect(userInfo).toEqual({
authType: "OAuth",
authType: "OAuth Token",
apiToken: "some-oauth-token",
email: "[email protected]",
accounts: [
Expand Down Expand Up @@ -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: "[email protected]",
accounts: [
Expand Down
15 changes: 11 additions & 4 deletions packages/wrangler/src/cfetch/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -97,10 +98,15 @@ async function requireLoggedIn(): Promise<void> {

function addAuthorizationHeaderIfUnspecified(
headers: Record<string, string>,
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;
}
}
}

Expand All @@ -125,8 +131,9 @@ export async function fetchKVGetValue(
key: string
): Promise<ArrayBuffer> {
await requireLoggedIn();
const apiToken = requireApiToken();
const headers = { Authorization: `Bearer ${apiToken}` };
const auth = requireApiToken();
const headers: Record<string, string> = {};
addAuthorizationHeaderIfUnspecified(headers, auth);
const resource = `${getCloudflareAPIBaseURL()}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${key}`;
const response = await fetch(resource, {
method: "GET",
Expand Down
37 changes: 33 additions & 4 deletions packages/wrangler/src/user/env-vars.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
57 changes: 25 additions & 32 deletions packages/wrangler/src/user/user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

/**
Expand Down Expand Up @@ -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 } =
Expand All @@ -369,11 +364,9 @@ function getAuthTokens(config?: UserAuthConfig): AuthTokens {
};
} else if (api_token) {
return { apiToken: api_token };
} else {
return {};
}
} catch {
return {};
return undefined;
}
}

Expand All @@ -392,31 +385,31 @@ 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" +
"If you wish to authenticate via an API token then please set the `CLOUDFLARE_API_TOKEN` environment variable."
);
}

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 {
Expand Down Expand Up @@ -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 {
Expand Down
29 changes: 17 additions & 12 deletions packages/wrangler/src/whoami.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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...");
Expand All @@ -25,8 +25,8 @@ export function WhoAmI({ user }: { user: UserInfo | undefined }) {
function Email(props: { tokenType: string; email: string }) {
return (
<Text>
👋 You are logged in with an {props.tokenType} Token, associated with the
email &apos;{props.email}&apos;!
👋 You are logged in with an {props.tokenType}, associated with the email
&apos;{props.email}&apos;!
</Text>
);
}
Expand All @@ -48,15 +48,20 @@ export interface UserInfo {

export async function getUserInfo(): Promise<UserInfo | undefined> {
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<string> {
Expand Down
4 changes: 3 additions & 1 deletion packages/wrangler/src/worker.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down

0 comments on commit c770167

Please sign in to comment.