Skip to content

Commit a9e5abb

Browse files
[wrangler] Add OAuth 2.0 Device Authorization Grant support (#14064)
1 parent daf65f2 commit a9e5abb

19 files changed

Lines changed: 1726 additions & 23 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@cloudflare/workers-auth": minor
3+
"wrangler": minor
4+
---
5+
6+
Add support for OAuth 2.0 Device Authorization Grant to `wrangler login`
7+
8+
Run `wrangler login --device` to authenticate without a local callback server. Useful in containers, remote SSH sessions, Codespaces, and any other environment where `localhost:8976` is unreachable from your browser.
9+
10+
The new flow:
11+
12+
- prints the verification URL and user code to the terminal,
13+
- attempts to open the verification URL in your default browser automatically (suppressed via `--browser=false`),
14+
- and polls the token endpoint until you approve the request (with a 5-minute hard cap).
15+
16+
The verification URL is supplied by the authorization server, so it is rejected unless it is an `https` URL on the same auth domain the device code was requested from — it is never printed or opened otherwise.
17+
18+
`--callback-host` and `--callback-port` cannot be combined with `--device`, since this flow does not start a local callback server.

packages/workers-auth/AGENTS.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ CLIs. Internal-only — published as `prerelease: true`.
1919
- `src/state.ts``readStoredAuthState()` + `StoredAuthState` shape
2020
- `src/token-exchange.ts` — auth-code → token + refresh-token rotation + `fetchAuthToken`
2121
- `src/callback-server.ts` — local HTTP server for the OAuth callback (listens on the host/port from the consumer's `redirectUri`)
22+
- `src/device-flow.ts``getOauthTokenViaDeviceFlow(options, ctx)`: the OAuth 2.0 Device Authorization Grant (RFC 8628) used by `login({device: true})` when the browser cannot reach the local callback URL. No callback server; prints the verification URL + user code and polls `/oauth2/token`. Every user-facing string is parameterised by `ctx.displayName` / `ctx.deviceLoginCommand` so the module carries no per-CLI branding. Both endpoints' bodies are narrowed before use (`asDeviceAuthorizationResponse`, `pollDeviceToken`'s `DevicePollResult`) rather than cast — an intermediary's envelope or a 2xx without a grant must produce a reportable `unexpected`/unusable-response error, never `undefined` arithmetic. `slow_down` / 5xx / 429 / unparseable bodies keep the loop alive; the last such failure is reported in place of the generic timeout if the deadline passes
23+
- `src/generate-device-auth-url.ts``generateVerificationUrl` (fallback `verification_uri_complete` builder for servers that omit it, RFC 8628 §3.3.1) + `assertTrustedVerificationUrl`, which rejects any server-supplied verification URL that is not `https:` on exactly the resolved auth domain (with no embedded credentials) before it is printed or handed to `openInBrowser` — the device flow is the only place this package opens a URL it did not build itself (RFC 8628 §5.4, remote phishing)
2224
- `src/flow.ts``createOAuthFlow(ctx)` factory wiring everything together
2325
- `src/context.ts``OAuthFlowContext` interface (DI surface)
2426
- `src/credential-store/` — opt-in OS-keyring-backed credential persistence (see below)
@@ -62,6 +64,11 @@ files and its own encryption key.
6264
- `clientId` (required) — the consumer's registered OAuth app ID; `string` or
6365
`() => string` for lazy (e.g. env-driven prod/staging) resolution
6466
- `consent` (required) — the consumer's branded granted/denied consent pages
67+
- `displayName` (required) — the consumer's branded name (`"Wrangler"`, `"cf"`),
68+
interpolated into the device flow's "To authorize \<name\>…" copy
69+
- `deviceLoginCommand` (required) — the command that restarts the device flow
70+
(`"wrangler login --device"`), quoted when a device code is denied, expires,
71+
or the flow times out
6572
- `redirectUri` (required) — the registered redirect URI / local callback URL.
6673
The callback server's listen host/port and route path are all derived from it
6774
(per-call bind overrides via `LoginProps.callbackHost`/`callbackPort`)
@@ -75,10 +82,10 @@ files and its own encryption key.
7582
- `generateAuthUrl?` / `generateRandomState?` — test overrides for deterministic
7683
snapshot tests (defaults pull from `./generate-auth-url` / `./generate-random-state`)
7784

78-
`clientId`, `consent`, `redirectUri`, and `storageFactory` are consumer-specific,
79-
so they are required rather than defaulted here. Wrangler's values live in the
80-
in-package wrangler layer (`src/wrangler/`, see below) rather than in the
81-
`wrangler` package itself.
85+
`clientId`, `consent`, `displayName`, `deviceLoginCommand`, `redirectUri`, and
86+
`storageFactory` are consumer-specific, so they are required rather than
87+
defaulted here. Wrangler's values live in the in-package wrangler layer
88+
(`src/wrangler/`, see below) rather than in the `wrangler` package itself.
8289

8390
The wrangler layer (`src/wrangler/index.ts`, `createWranglerAuth`) wires the
8491
credential-storage layer once via `createCredentialStorageContext(...)` and
@@ -99,7 +106,9 @@ Cloudflare CLI built on this package. It lives in `src/core/`:
99106
only `@cloudflare/workers-utils`.
100107
- `types.ts``AuthContext` (the injected primitives: `logger`, `userAgent`,
101108
interactive `prompt` / `select`, `isNoDefaultValueProvidedError`) and
102-
`CliDescriptor` (everything that varies per CLI: `cliName`, auth command names, `keyringServiceName`,
109+
`CliDescriptor` (everything that varies per CLI: `cliName` (the executable),
110+
`displayName` (branded name used in prose), auth command names
111+
(`login` / `whoami` / `createProfile` / `deviceLogin`), `keyringServiceName`,
103112
`clientId`, `consent`, `redirectUri`, `getConfigPath`, `fileFormat`,
104113
`accountCachePrefix`, `cacheNamespace`, `getConfigFileLabel`,
105114
`getDefaultScopeKeys`, …).

packages/workers-auth/src/cf/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export const CF_KEYRING_SERVICE_NAME = "cloudflare";
1212
/** CLI name used for keyring install-dir scoping and user-facing messaging. */
1313
export const CF_CLI_NAME = "cf";
1414

15+
/** cf's branded name, used in prose addressed to the user. */
16+
export const CF_DISPLAY_NAME = "cf";
17+
1518
/**
1619
* The `redirect_uri` registered on cf's OAuth app; also the local callback URL.
1720
* cf uses the fixed local callback port 8877 (from its historical 8877–8886

packages/workers-auth/src/cf/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createCloudflareProfileStore } from "../core/profile-store";
1414
import {
1515
CF_CLI_NAME,
1616
CF_CONSENT_PAGES,
17+
CF_DISPLAY_NAME,
1718
CF_KEYRING_SERVICE_NAME,
1819
CF_OAUTH_CALLBACK_URL,
1920
} from "./constants";
@@ -66,10 +67,12 @@ export type { UserPreferences } from "../core/preferences";
6667
*/
6768
export const CF_CLI: CliDescriptor = {
6869
cliName: CF_CLI_NAME,
70+
displayName: CF_DISPLAY_NAME,
6971
commands: {
7072
login: "cf auth login",
7173
whoami: "cf auth whoami",
7274
createProfile: "cf auth create",
75+
deviceLogin: "cf auth login --device",
7376
},
7477
keyringServiceName: CF_KEYRING_SERVICE_NAME,
7578
clientId: getClientIdFromEnv,

packages/workers-auth/src/context.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,20 @@ export interface OAuthFlowContext {
9393
*/
9494
consent: OAuthConsentPages;
9595

96+
/**
97+
* The consuming CLI's branded name (e.g. `"Wrangler"`, `"cf"`), interpolated
98+
* into the copy the flow prints to the user — "To authorize <name>, please
99+
* visit ...". Consumer-specific, so it is required.
100+
*/
101+
displayName: string;
102+
103+
/**
104+
* The command that restarts the device authorization flow (e.g.
105+
* `"wrangler login --device"`), quoted when a device code is denied,
106+
* expires, or the flow times out. Consumer-specific, so it is required.
107+
*/
108+
deviceLoginCommand: string;
109+
96110
/**
97111
* The `redirect_uri` registered on the consumer's OAuth app
98112
*/

packages/workers-auth/src/core/factory.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ export interface CloudflareLoginProps {
6363
callbackHost?: string;
6464
callbackPort?: number;
6565
profile?: string;
66+
/**
67+
* When `true`, authenticate using the OAuth 2.0 Device Authorization Grant
68+
* (RFC 8628) instead of the authorization-code-with-PKCE callback flow. The
69+
* device flow does not start a local callback server, so `callbackHost` and
70+
* `callbackPort` are ignored when this is set.
71+
*/
72+
device?: boolean;
6673
}
6774

6875
/** A Cloudflare CLI's auth layer, returned by {@link createCloudflareAuth}. */
@@ -236,6 +243,8 @@ export function createCloudflareAuth(
236243
purgeOnLoginOrLogout: configCache.purgeConfigCaches,
237244
clientId: descriptor.clientId,
238245
consent: descriptor.consent,
246+
displayName: descriptor.displayName,
247+
deviceLoginCommand: descriptor.commands.deviceLogin,
239248
redirectUri: descriptor.redirectUri,
240249
storageFactory: credentialStorage.storageFactory,
241250
allowGlobalAuthKey,
@@ -286,6 +295,7 @@ export function createCloudflareAuth(
286295
callbackHost: props?.callbackHost,
287296
callbackPort: props?.callbackPort,
288297
profile: props?.profile,
298+
device: props?.device,
289299
};
290300
}
291301

packages/workers-auth/src/core/types.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,31 @@ export interface AuthContext {
4545
* rather than a fork of the factory.
4646
*/
4747
export interface CliDescriptor {
48-
/** CLI name used in user-facing messaging and keyring install-dir scoping (e.g. `"wrangler"`). */
48+
/**
49+
* The CLI's invocation name, used for keyring install-dir scoping and in
50+
* messaging that refers to the executable (e.g. `"wrangler"`, `"cf"`).
51+
*/
4952
cliName: string;
5053

54+
/**
55+
* The CLI's branded name, used in prose addressed to the user (e.g.
56+
* `"Wrangler"` for wrangler, `"cf"` for cf). Distinct from
57+
* {@link CliDescriptor.cliName}, which names the executable.
58+
*/
59+
displayName: string;
60+
5161
/** Commands surfaced in auth guidance. */
5262
commands: {
5363
login: string;
5464
whoami: string;
5565
createProfile: string;
66+
/**
67+
* The command that restarts the OAuth 2.0 Device Authorization Grant,
68+
* surfaced when a device code is denied, expires, or times out. Spelled
69+
* out per CLI rather than derived from {@link CliDescriptor.commands.login}
70+
* so the flag name stays the CLI's business.
71+
*/
72+
deviceLogin: string;
5673
};
5774

5875
/**

0 commit comments

Comments
 (0)