Skip to content

Commit b993d95

Browse files
feat(wrangler): add wrangler auth token command (#11682)
* feat(wrangler): add `wrangler auth token` command Add a new command `wrangler auth token` that retrieves the current OAuth token, refreshing it if necessary. This is similar to `gh auth token` in the GitHub CLI and allows users to easily retrieve their authentication token for use with other tools and scripts. Fixes #10095 Co-Authored-By: mkane@cloudflare.com <m@mk.gg> * fix: output auth token without preamble and update snapshots Co-Authored-By: mkane@cloudflare.com <m@mk.gg> * fix: use printBanner: false for proper preamble suppression Co-Authored-By: mkane@cloudflare.com <m@mk.gg> * docs: add usage example to changeset per review Co-Authored-By: mkane@cloudflare.com <m@mk.gg> * feat(wrangler): add --json flag and refactor auth token command - Refactor getAuthToken to getOAuthTokenFromLocalState (only handles OAuth) - Rename LocalState to localState throughout user.ts - Add --json flag to return structured output with token type - Support API key/email auth with --json flag - Add JSDoc documentation for auth priority behavior - Update changeset to reflect all auth types - Update tests (24 tests passing) Co-Authored-By: mkane@cloudflare.com <m@mk.gg> * chore: trigger CI re-run for PR description validation Co-Authored-By: mkane@cloudflare.com <m@mk.gg> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 726dfc1 commit b993d95

File tree

7 files changed

+367
-30
lines changed

7 files changed

+367
-30
lines changed

.changeset/auth-token-command.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
Add `wrangler auth token` command to retrieve your current authentication credentials.
6+
7+
You can now retrieve your authentication token for use with other tools and scripts:
8+
9+
```bash
10+
wrangler auth token
11+
```
12+
13+
The command returns whichever authentication method is currently configured:
14+
- OAuth token from `wrangler login` (automatically refreshed if expired)
15+
- API token from `CLOUDFLARE_API_TOKEN` environment variable
16+
17+
Use `--json` to get structured output including the token type, which also supports API key/email authentication:
18+
19+
```bash
20+
wrangler auth token --json
21+
```
22+
23+
This is similar to `gh auth token` in the GitHub CLI.

packages/wrangler/src/__tests__/index.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ describe("wrangler", () => {
7171
wrangler login 🔓 Login to Cloudflare
7272
wrangler logout 🚪 Logout from Cloudflare
7373
wrangler whoami 🕵️ Retrieve your user information
74+
wrangler auth 🔐 Manage authentication
7475
7576
GLOBAL FLAGS
7677
-c, --config Path to Wrangler configuration file [string]
@@ -135,6 +136,7 @@ describe("wrangler", () => {
135136
wrangler login 🔓 Login to Cloudflare
136137
wrangler logout 🚪 Logout from Cloudflare
137138
wrangler whoami 🕵️ Retrieve your user information
139+
wrangler auth 🔐 Manage authentication
138140
139141
GLOBAL FLAGS
140142
-c, --config Path to Wrangler configuration file [string]

packages/wrangler/src/__tests__/user.test.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
1111
import { CI } from "../is-ci";
1212
import {
1313
getAuthConfigFilePath,
14+
getOAuthTokenFromLocalState,
1415
loginOrRefreshIfRequired,
1516
readAuthConfigFile,
1617
requireAuth,
@@ -334,4 +335,174 @@ describe("User", () => {
334335
scopes: ["account:read"],
335336
});
336337
});
338+
339+
describe("auth token", () => {
340+
it("should output the OAuth token when logged in with a valid token", async () => {
341+
// Set up a valid, non-expired token
342+
const futureDate = new Date(Date.now() + 100000 * 1000).toISOString();
343+
writeAuthConfigFile({
344+
oauth_token: "test-access-token",
345+
refresh_token: "test-refresh-token",
346+
expiration_time: futureDate,
347+
scopes: ["account:read"],
348+
});
349+
350+
await runWrangler("auth token");
351+
352+
expect(std.out).toContain("test-access-token");
353+
});
354+
355+
it("should refresh and output the token when the token is expired", async () => {
356+
// Set up an expired token
357+
const pastDate = new Date(Date.now() - 100000 * 1000).toISOString();
358+
writeAuthConfigFile({
359+
oauth_token: "expired-token",
360+
refresh_token: "test-refresh-token",
361+
expiration_time: pastDate,
362+
scopes: ["account:read"],
363+
});
364+
365+
mockExchangeRefreshTokenForAccessToken({ respondWith: "refreshSuccess" });
366+
367+
await runWrangler("auth token");
368+
369+
// The token should have been refreshed (mock returns "access_token_success_mock")
370+
expect(std.out).toContain("access_token_success_mock");
371+
});
372+
373+
it("should error when not logged in", async () => {
374+
await expect(runWrangler("auth token")).rejects.toThrowError(
375+
"Not logged in. Please run `wrangler login` to authenticate."
376+
);
377+
});
378+
379+
it("should output the API token from environment variable", async () => {
380+
vi.stubEnv("CLOUDFLARE_API_TOKEN", "env-api-token");
381+
382+
await runWrangler("auth token");
383+
384+
expect(std.out).toContain("env-api-token");
385+
});
386+
387+
it("should error when using global auth key/email without --json", async () => {
388+
vi.stubEnv("CLOUDFLARE_API_KEY", "test-api-key");
389+
vi.stubEnv("CLOUDFLARE_EMAIL", "test@example.com");
390+
391+
await expect(runWrangler("auth token")).rejects.toThrowError(
392+
"Cannot output a single token when using CLOUDFLARE_API_KEY and CLOUDFLARE_EMAIL"
393+
);
394+
});
395+
396+
it("should output JSON with key and email when using global auth key/email with --json", async () => {
397+
vi.stubEnv("CLOUDFLARE_API_KEY", "test-api-key");
398+
vi.stubEnv("CLOUDFLARE_EMAIL", "test@example.com");
399+
400+
await runWrangler("auth token --json");
401+
402+
const output = JSON.parse(std.out);
403+
expect(output).toEqual({
404+
type: "api_key",
405+
key: "test-api-key",
406+
email: "test@example.com",
407+
});
408+
});
409+
410+
it("should output JSON with oauth type when logged in with --json", async () => {
411+
const futureDate = new Date(Date.now() + 100000 * 1000).toISOString();
412+
writeAuthConfigFile({
413+
oauth_token: "test-access-token",
414+
refresh_token: "test-refresh-token",
415+
expiration_time: futureDate,
416+
scopes: ["account:read"],
417+
});
418+
419+
await runWrangler("auth token --json");
420+
421+
const output = JSON.parse(std.out);
422+
expect(output).toEqual({
423+
type: "oauth",
424+
token: "test-access-token",
425+
});
426+
});
427+
428+
it("should output JSON with api_token type when using CLOUDFLARE_API_TOKEN with --json", async () => {
429+
vi.stubEnv("CLOUDFLARE_API_TOKEN", "env-api-token");
430+
431+
await runWrangler("auth token --json");
432+
433+
const output = JSON.parse(std.out);
434+
expect(output).toEqual({
435+
type: "api_token",
436+
token: "env-api-token",
437+
});
438+
});
439+
440+
it("should error when token refresh fails and user is not logged in", async () => {
441+
// Set up an expired token with a refresh token that will fail
442+
const pastDate = new Date(Date.now() - 100000 * 1000).toISOString();
443+
writeAuthConfigFile({
444+
oauth_token: "expired-token",
445+
refresh_token: "invalid-refresh-token",
446+
expiration_time: pastDate,
447+
scopes: ["account:read"],
448+
});
449+
450+
mockExchangeRefreshTokenForAccessToken({ respondWith: "refreshError" });
451+
452+
await expect(runWrangler("auth token")).rejects.toThrowError(
453+
"Not logged in. Please run `wrangler login` to authenticate."
454+
);
455+
});
456+
});
457+
458+
describe("getOAuthTokenFromLocalState", () => {
459+
it("should return undefined when not logged in", async () => {
460+
const token = await getOAuthTokenFromLocalState();
461+
expect(token).toBeUndefined();
462+
});
463+
464+
it("should return the OAuth token when logged in with a valid token", async () => {
465+
const futureDate = new Date(Date.now() + 100000 * 1000).toISOString();
466+
writeAuthConfigFile({
467+
oauth_token: "test-oauth-token",
468+
refresh_token: "test-refresh-token",
469+
expiration_time: futureDate,
470+
scopes: ["account:read"],
471+
});
472+
473+
const token = await getOAuthTokenFromLocalState();
474+
expect(token).toBe("test-oauth-token");
475+
});
476+
477+
it("should refresh and return the token when expired", async () => {
478+
const pastDate = new Date(Date.now() - 100000 * 1000).toISOString();
479+
writeAuthConfigFile({
480+
oauth_token: "expired-token",
481+
refresh_token: "test-refresh-token",
482+
expiration_time: pastDate,
483+
scopes: ["account:read"],
484+
});
485+
486+
mockExchangeRefreshTokenForAccessToken({ respondWith: "refreshSuccess" });
487+
488+
const token = await getOAuthTokenFromLocalState();
489+
// Mock returns "access_token_success_mock" for refreshSuccess
490+
expect(token).toBe("access_token_success_mock");
491+
});
492+
493+
it("should return undefined when token refresh fails", async () => {
494+
const pastDate = new Date(Date.now() - 100000 * 1000).toISOString();
495+
writeAuthConfigFile({
496+
oauth_token: "expired-token",
497+
refresh_token: "invalid-refresh-token",
498+
expiration_time: pastDate,
499+
scopes: ["account:read"],
500+
});
501+
502+
mockExchangeRefreshTokenForAccessToken({ respondWith: "refreshError" });
503+
504+
const token = await getOAuthTokenFromLocalState();
505+
expect(token).toBeUndefined();
506+
});
507+
});
337508
});

packages/wrangler/src/index.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,13 @@ import { setupCommand } from "./setup";
285285
import { tailCommand } from "./tail";
286286
import { triggersDeployCommand, triggersNamespace } from "./triggers";
287287
import { typesCommand } from "./type-generation";
288-
import { loginCommand, logoutCommand, whoamiCommand } from "./user/commands";
288+
import {
289+
authNamespace,
290+
authTokenCommand,
291+
loginCommand,
292+
logoutCommand,
293+
whoamiCommand,
294+
} from "./user/commands";
289295
import { betaCmdColor, proxy } from "./utils/constants";
290296
import { debugLogFilepath } from "./utils/log-file";
291297
import { vectorizeCreateCommand } from "./vectorize/create";
@@ -1555,6 +1561,18 @@ export function createCLIParser(argv: string[]) {
15551561
]);
15561562
registry.registerNamespace("whoami");
15571563

1564+
registry.define([
1565+
{
1566+
command: "wrangler auth",
1567+
definition: authNamespace,
1568+
},
1569+
{
1570+
command: "wrangler auth token",
1571+
definition: authTokenCommand,
1572+
},
1573+
]);
1574+
registry.registerNamespace("auth");
1575+
15581576
registry.define([
15591577
{
15601578
command: "wrangler telemetry",

packages/wrangler/src/metrics/send-event.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export type EventNames =
3838
| "delete r2 bucket"
3939
| "login user"
4040
| "logout user"
41+
| "retrieve auth token"
4142
| "create pubsub namespace"
4243
| "list pubsub namespaces"
4344
| "delete pubsub namespace"

packages/wrangler/src/user/commands.ts

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
1-
import { CommandLineArgsError } from "@cloudflare/workers-utils";
2-
import { createCommand } from "../core/create-command";
1+
import { CommandLineArgsError, UserError } from "@cloudflare/workers-utils";
2+
import { createCommand, createNamespace } from "../core/create-command";
3+
import { logger } from "../logger";
34
import * as metrics from "../metrics";
4-
import { listScopes, login, logout, validateScopeKeys } from "./user";
5+
import {
6+
getAuthFromEnv,
7+
getOAuthTokenFromLocalState,
8+
listScopes,
9+
login,
10+
logout,
11+
validateScopeKeys,
12+
} from "./user";
513
import { whoami } from "./whoami";
614

15+
/**
16+
* Represents the authentication information returned by `wrangler auth token --json`.
17+
*/
18+
export type AuthTokenInfo =
19+
| { type: "oauth"; token: string }
20+
| { type: "api_token"; token: string }
21+
| { type: "api_key"; key: string; email: string };
22+
723
export const loginCommand = createCommand({
824
metadata: {
925
description: "🔓 Login to Cloudflare",
@@ -121,3 +137,75 @@ export const whoamiCommand = createCommand({
121137
});
122138
},
123139
});
140+
141+
export const authNamespace = createNamespace({
142+
metadata: {
143+
description: "🔐 Manage authentication",
144+
owner: "Workers: Authoring and Testing",
145+
status: "stable",
146+
},
147+
});
148+
149+
export const authTokenCommand = createCommand({
150+
metadata: {
151+
description: "🔑 Retrieve the current authentication token or credentials",
152+
owner: "Workers: Authoring and Testing",
153+
status: "stable",
154+
},
155+
behaviour: {
156+
printBanner: (args) => !args.json,
157+
printConfigWarnings: false,
158+
},
159+
args: {
160+
json: {
161+
type: "boolean",
162+
description: "Return output as JSON with token type information",
163+
default: false,
164+
},
165+
},
166+
async handler({ json }, { config }) {
167+
const authFromEnv = getAuthFromEnv();
168+
169+
let result: AuthTokenInfo;
170+
171+
if (authFromEnv) {
172+
if ("apiToken" in authFromEnv) {
173+
// API token from CLOUDFLARE_API_TOKEN
174+
result = { type: "api_token", token: authFromEnv.apiToken };
175+
} else {
176+
// Global API key + email from CLOUDFLARE_API_KEY + CLOUDFLARE_EMAIL
177+
result = {
178+
type: "api_key",
179+
key: authFromEnv.authKey,
180+
email: authFromEnv.authEmail,
181+
};
182+
}
183+
} else {
184+
// OAuth token from local state (wrangler login)
185+
const token = await getOAuthTokenFromLocalState();
186+
if (!token) {
187+
throw new UserError(
188+
"Not logged in. Please run `wrangler login` to authenticate."
189+
);
190+
}
191+
result = { type: "oauth", token };
192+
}
193+
194+
if (json) {
195+
logger.log(JSON.stringify(result, null, 2));
196+
} else {
197+
// For non-JSON output, only output a single token for scripting
198+
if (result.type === "api_key") {
199+
throw new UserError(
200+
"Cannot output a single token when using CLOUDFLARE_API_KEY and CLOUDFLARE_EMAIL.\n" +
201+
"Use --json to get both key and email, or use CLOUDFLARE_API_TOKEN instead."
202+
);
203+
}
204+
logger.log(result.token);
205+
}
206+
207+
metrics.sendMetricsEvent("retrieve auth token", {
208+
sendMetrics: config.send_metrics,
209+
});
210+
},
211+
});

0 commit comments

Comments
 (0)