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

Feature: add whoami #284

Merged
merged 2 commits into from
Jan 24, 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
5 changes: 5 additions & 0 deletions .changeset/cyan-comics-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"wrangler": patch
---

Add whoami command
2 changes: 2 additions & 0 deletions packages/wrangler/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe("wrangler", () => {

Commands:
wrangler init [name] 📥 Create a wrangler.toml configuration file
wrangler whoami 🕵️ Retrieve your user info and test your auth config
wrangler dev <filename> 👂 Start a local server for developing your worker
wrangler publish [script] 🆙 Publish your Worker to Cloudflare.
wrangler tail [name] 🦚 Starts a log tailing session for a deployed Worker.
Expand Down Expand Up @@ -49,6 +50,7 @@ describe("wrangler", () => {

Commands:
wrangler init [name] 📥 Create a wrangler.toml configuration file
wrangler whoami 🕵️ Retrieve your user info and test your auth config
wrangler dev <filename> 👂 Start a local server for developing your worker
wrangler publish [script] 🆙 Publish your Worker to Cloudflare.
wrangler tail [name] 🦚 Starts a log tailing session for a deployed Worker.
Expand Down
130 changes: 130 additions & 0 deletions packages/wrangler/src/__tests__/whoami.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import React from "react";
import os from "node:os";
import path from "node:path";
import { render } from "ink-testing-library";
import type { UserInfo } from "../whoami";
import { getUserInfo, WhoAmI } from "../whoami";
import { runInTempDir } from "./run-in-tmp";
import { mkdirSync, writeFileSync } from "node:fs";
import { setMockResponse } from "./mock-cfetch";
import { initialise } from "../user";

const ORIGINAL_CF_API_TOKEN = process.env.CF_API_TOKEN;
const ORIGINAL_CF_ACCOUNT_ID = process.env.CF_ACCOUNT_ID;

describe("getUserInfo()", () => {
runInTempDir();

beforeEach(() => {
// Clear the environment variables, so we can control them in the tests
delete process.env.CF_API_TOKEN;
delete process.env.CF_ACCOUNT_ID;
// Override where the home directory is so that we can specify a user config
mkdirSync("./home");
jest.spyOn(os, "homedir").mockReturnValue("./home");
});

afterEach(() => {
// Reset any changes to the environment variables
process.env.CF_API_TOKEN = ORIGINAL_CF_API_TOKEN;
process.env.CF_ACCOUNT_ID = ORIGINAL_CF_ACCOUNT_ID;
});

it("should return undefined if there is no config file", async () => {
await initialise();
const userInfo = await getUserInfo();
expect(userInfo).toBeUndefined();
});

it("should return undefined if there is an empty config file", async () => {
writeUserConfig();
await initialise();
const userInfo = await getUserInfo();
expect(userInfo).toBeUndefined();
});

it("should return the user's email and accounts if authenticated via config token", async () => {
writeUserConfig("some-oauth-token");
setMockResponse("/user", () => {
return { 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" },
];
});

await initialise();
const userInfo = await getUserInfo();

expect(userInfo).toEqual({
authType: "OAuth",
apiToken: "some-oauth-token",
email: "user@example.com",
accounts: [
{ name: "Account One", id: "account-1" },
{ name: "Account Two", id: "account-2" },
{ name: "Account Three", id: "account-3" },
],
});
});
});

describe("WhoAmI component", () => {
it("should return undefined if there is no user", async () => {
const { lastFrame } = render(<WhoAmI user={undefined}></WhoAmI>);

expect(lastFrame()).toMatchInlineSnapshot(
`"You are not authenticated. Please run \`wrangler login\`."`
);
});

it("should display the user's email and accounts", async () => {
const user: UserInfo = {
authType: "OAuth",
apiToken: "some-oauth-token",
email: "user@example.com",
accounts: [
{ name: "Account One", id: "account-1" },
{ name: "Account Two", id: "account-2" },
{ name: "Account Three", id: "account-3" },
],
};

const { lastFrame } = render(<WhoAmI user={user}></WhoAmI>);

expect(lastFrame()).toContain(
"You are logged in with an OAuth Token, associated with the email 'user@example.com'!"
);
expect(lastFrame()).toMatch(/Account Name .+ Account ID/);
expect(lastFrame()).toMatch(/Account One .+ account-1/);
expect(lastFrame()).toMatch(/Account Two .+ account-2/);
expect(lastFrame()).toMatch(/Account Three .+ account-3/);
});
});

function writeUserConfig(
oauth_token?: string,
refresh_token?: string,
expiration_time?: string
) {
const lines: string[] = [];
if (oauth_token) {
lines.push(`oauth_token = "${oauth_token}"`);
}
if (refresh_token) {
lines.push(`refresh_token = "${refresh_token}"`);
}
if (expiration_time) {
lines.push(`expiration_time = "${expiration_time}"`);
}
const configPath = path.join(os.homedir(), ".wrangler/config");
mkdirSync(configPath, { recursive: true });
writeFileSync(
path.join(configPath, "default.toml"),
lines.join("\n"),
"utf-8"
);
}
14 changes: 10 additions & 4 deletions packages/wrangler/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
initialise as initialiseUserConfig,
loginOrRefreshIfRequired,
getAccountId,
validateScopeKeys,
} from "./user";
import {
getNamespaceId,
Expand All @@ -44,6 +45,7 @@ import onExit from "signal-exit";
import { setTimeout } from "node:timers/promises";
import * as fs from "node:fs";
import { execa } from "execa";
import { whoami } from "./whoami";

const resetColor = "\x1b[0m";
const fgGreenColor = "\x1b[32m";
Expand Down Expand Up @@ -332,6 +334,11 @@ export async function main(argv: string[]): Promise<void> {
listScopes();
return;
}
if (!validateScopeKeys(args.scopes)) {
throw new CommandLineArgsError(
`One of ${args.scopes} is not a valid authentication scope. Run "wrangler login --list-scopes" to see the valid scopes.`
);
}
await login({ scopes: args.scopes });
return;
}
Expand All @@ -358,11 +365,10 @@ export async function main(argv: string[]): Promise<void> {
// whoami
wrangler.command(
"whoami",
false, // we don't need to show this the menu
// "🕵️ Retrieve your user info and test your auth config",
"🕵️ Retrieve your user info and test your auth config",
() => {},
(args) => {
console.log(":whoami", args);
async () => {
await whoami();
}
);

Expand Down
84 changes: 38 additions & 46 deletions packages/wrangler/src/user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,38 +254,37 @@ interface AccessToken {
expiry: string;
}

type Scope =
| "account:read"
| "user:read"
| "workers:write"
| "workers_kv:write"
| "workers_routes:write"
| "workers_scripts:write"
| "workers_tail:read"
| "zone:read"
| "offline_access"; // this should be included by default

const Scopes: Scope[] = [
"account:read",
"user:read",
"workers:write",
"workers_kv:write",
"workers_routes:write",
"workers_scripts:write",
"workers_tail:read",
"zone:read",
];

const ScopeDescriptions = [
"See your account info such as account details, analytics, and memberships.",
"See your user info such as name, email address, and account memberships.",
"See and change Cloudflare Workers data such as zones, KV storage, namespaces, scripts, and routes.",
"See and change Cloudflare Workers KV Storage data such as keys and namespaces.",
"See and change Cloudflare Workers data such as filters and routes.",
"See and change Cloudflare Workers scripts, durable objects, subdomains, triggers, and tail data.",
"See Cloudflare Workers tail and script data.",
"Grants read level access to account zone.",
];
const Scopes = {
"account:read":
"See your account info such as account details, analytics, and memberships.",
"user:read":
"See your user info such as name, email address, and account memberships.",
"workers:write":
"See and change Cloudflare Workers data such as zones, KV storage, namespaces, scripts, and routes.",
"workers_kv:write":
"See and change Cloudflare Workers KV Storage data such as keys and namespaces.",
"workers_routes:write":
"See and change Cloudflare Workers data such as filters and routes.",
"workers_scripts:write":
"See and change Cloudflare Workers scripts, durable objects, subdomains, triggers, and tail data.",
"workers_tail:read": "See Cloudflare Workers tail and script data.",
"zone:read": "Grants read level access to account zone.",
} as const;

/**
* The possible keys for a Scope.
*
* "offline_access" is automatically included.
*/
type Scope = keyof typeof Scopes | "offline_access";

const ScopeKeys = Object.keys(Scopes) as Scope[];

export function validateScopeKeys(
scopes: string[]
): scopes is typeof ScopeKeys {
return scopes.every((scope) => Scopes[scope]);
}

const CLIENT_ID = "54d11594-84e4-41aa-b438-e81b8fa78ee7";
const AUTH_URL = "https://dash.cloudflare.com/oauth2/auth";
Expand Down Expand Up @@ -344,15 +343,12 @@ function throwIfNotInitialised() {
}
}

export function getAPIToken(): string {
export function getAPIToken(): string | undefined {
if (process.env.CF_API_TOKEN) {
return process.env.CF_API_TOKEN;
}

throwIfNotInitialised();
// `throwIfNotInitialised()` ensures that the accessToken is guaranteed to be defined.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return LocalState.accessToken!.value;
return LocalState.accessToken?.value;
}

interface AccessContext {
Expand Down Expand Up @@ -550,7 +546,7 @@ function isReturningFromAuthServer(query: ParsedUrlQuery): boolean {
return true;
}

export async function getAuthURL(scopes?: string[]): Promise<string> {
export async function getAuthURL(scopes = ScopeKeys): Promise<string> {
const { codeChallenge, codeVerifier } = await generatePKCECodes();
const stateQueryParam = generateRandomState(RECOMMENDED_STATE_LENGTH);

Expand All @@ -560,16 +556,12 @@ export async function getAuthURL(scopes?: string[]): Promise<string> {
stateQueryParam,
});

// TODO: verify that the scopes passed are legit

return (
AUTH_URL +
`?response_type=code&` +
`client_id=${encodeURIComponent(CLIENT_ID)}&` +
`redirect_uri=${encodeURIComponent(CALLBACK_URL)}&` +
`scope=${encodeURIComponent(
(scopes || Scopes).concat("offline_access").join(" ")
)}&` +
`scope=${encodeURIComponent(scopes.concat("offline_access").join(" "))}&` +
`state=${stateQueryParam}&` +
`code_challenge=${encodeURIComponent(codeChallenge)}&` +
`code_challenge_method=S256`
Expand Down Expand Up @@ -794,7 +786,7 @@ expiration_time = "${tokenData.token?.expiry}"
}

type LoginProps = {
scopes?: string[];
scopes?: Scope[];
};

export async function loginOrRefreshIfRequired(): Promise<boolean> {
Expand Down Expand Up @@ -947,9 +939,9 @@ export async function logout(): Promise<void> {
export function listScopes(): void {
throwIfNotInitialised();
console.log("💁 Available scopes:");
const data = Scopes.map((scope, index) => ({
const data = ScopeKeys.map((scope) => ({
Scope: scope,
Description: ScopeDescriptions[index],
Description: Scopes[scope],
}));
render(<Table data={data} />);
// TODO: maybe a good idea to show usage here
Expand Down
Loading