Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Global API Key (X-Auth-Key) support to Wrangler #1351

Merged
merged 3 commits into from
Jun 27, 2022
Merged
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/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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
? "Global API Key"
? "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