Skip to content

Commit 1cea129

Browse files
authored
add auth profiles to wrangler (#14200)
1 parent 9ed7779 commit 1cea129

62 files changed

Lines changed: 1801 additions & 958 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/auth-profiles.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
Add auth profiles for managing multiple OAuth logins
6+
7+
Auth profiles let you maintain separate OAuth logins and bind them to directories, so you can switch between different accounts for different projects without having to re-login.
8+
9+
For example:
10+
11+
```sh
12+
wrangler auth create work
13+
wrangler auth activate work ~/projects/work
14+
15+
wrangler auth create personal
16+
wrangler auth activate personal ~/projects/personal
17+
```
18+
19+
New commands under `wrangler auth`:
20+
21+
- `wrangler auth create <name>` — create or re-authenticate a named profile via OAuth
22+
- `wrangler auth delete <name>` — delete a profile and all its directory bindings
23+
- `wrangler auth activate <name> [dir]` — bind a profile to a directory (defaults to cwd). Sub-directories will inherit this profile.
24+
- `wrangler auth deactivate [dir]` — remove a directory binding
25+
- `wrangler auth list` — list all profiles and their corresponding directories
26+
27+
There is also a new global `--profile` flag, which you can use to activate a profile for just that command run. Note that if you have `CLOUDFLARE_API_TOKEN` set, that will still take precedence over all profiles. Any account id settings (via `CLOUDFLARE_ACCOUNT_ID` or wrangler config) will also still be respected.

packages/workers-auth/src/context.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,15 @@ export interface OAuthFlowContext {
104104
redirectUri: string;
105105

106106
/**
107-
* Persistence backend for the stored auth config.
107+
* Factory that returns a persistence backend for the given auth profile.
108+
*
109+
* Called with the active profile name (e.g. `"default"`) on every storage
110+
* access so the flow always reads/writes the correct backing store.
111+
* The consumer is responsible for mapping profile names to concrete storage
112+
* backends (e.g. wrangler maps each profile to a separate TOML file under
113+
* the global config directory).
108114
*/
109-
storage: AuthConfigStorage;
115+
storageFactory: (profile?: string) => AuthConfigStorage;
110116

111117
/**
112118
* Whether the flow's credential resolvers (`getAPIToken` / `requireApiToken`)

packages/workers-auth/src/flow.ts

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { generateRandomState as defaultGenerateRandomState } from "./generate-ra
2626
import { readStoredAuthState, type OAuthFlowState } from "./state";
2727
import { getOrCreateTemporaryPreviewAccount } from "./temporary";
2828
import { exchangeRefreshTokenForAccessToken } from "./token-exchange";
29+
import type { AuthConfigStorage } from "./config-file/auth";
2930
import type { TemporaryPreviewAccount } from "./config-file/temporary";
3031
import type { OAuthFlowContext } from "./context";
3132
import type {
@@ -77,12 +78,28 @@ export interface LoginProps {
7778
callbackHost?: string;
7879
/** Port the local callback server listens on. Defaults to `8976`. */
7980
callbackPort?: number;
81+
/**
82+
* Named auth profile to store the token under. When omitted, the default
83+
* profile (`default.toml`) is used.
84+
* Only for use by 'auth create', not exposed to the user
85+
*/
86+
profile?: string;
8087
}
8188

8289
/**
8390
* Public surface returned by {@link createOAuthFlow}.
8491
*/
8592
export interface OAuthFlowAPI {
93+
/**
94+
* Set the active auth profile for all subsequent storage lookups.
95+
*
96+
* Called once at top of command dispatch by the consumer after resolving the
97+
* active profile. `"default"` if never called.
98+
*/
99+
setProfile(profile: string): void;
100+
101+
getActiveProfile(): string;
102+
86103
/**
87104
* Open the authorize URL in the user's browser, wait for the callback to be
88105
* hit on the local HTTP server, exchange the code for an access token, and
@@ -91,6 +108,9 @@ export interface OAuthFlowAPI {
91108
* Refuses to start when `ctx.hasEnvCredentials()` returns `true`.
92109
* Refuses to start when the compliance region is `fedramp_high`.
93110
*
111+
* When `props.profile` is set, the token is stored under that profile
112+
* instead of the active one. This is used by `auth create <name>`.
113+
*
94114
* @returns `true` on success, `false` when env credentials are present.
95115
*/
96116
login(props: LoginProps): Promise<boolean>;
@@ -101,8 +121,11 @@ export interface OAuthFlowAPI {
101121
*
102122
* No-op when `ctx.hasEnvCredentials()` returns `true` (env credentials
103123
* cannot be revoked).
124+
*
125+
* When `profile` is passed, operates on that profile instead of the
126+
* active one. This is used by `auth delete <name>`.
104127
*/
105-
logout(): Promise<void>;
128+
logout(profile?: string): Promise<void>;
106129

107130
/**
108131
* If the user has no stored OAuth token, attempt an interactive login.
@@ -144,6 +167,12 @@ export interface OAuthFlowAPI {
144167
*/
145168
requireApiToken(): ApiCredentials;
146169

170+
/**
171+
* Return the scopes granted to the stored OAuth token for the active
172+
* profile, or `undefined` when no OAuth token is stored.
173+
*/
174+
getScopes(): string[] | undefined;
175+
147176
/**
148177
* Establish whether `--temporary` is permitted for this invocation. Called
149178
* once at command dispatch by the consumer. Also drops any temporary account
@@ -190,7 +219,12 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI {
190219
generateRandomState: ctx.generateRandomState ?? defaultGenerateRandomState,
191220
};
192221

193-
const storage = ctx.storage;
222+
let activeProfile = "default";
223+
224+
function getStorage(profile?: string): AuthConfigStorage {
225+
return ctx.storageFactory(profile ?? activeProfile);
226+
}
227+
194228
const getClientId = () =>
195229
typeof ctx.clientId === "function" ? ctx.clientId() : ctx.clientId;
196230
const consent = ctx.consent;
@@ -249,7 +283,7 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI {
249283
generators
250284
);
251285

252-
storage.write({
286+
getStorage(props.profile).write({
253287
oauth_token: oauth.token?.value ?? "",
254288
expiration_time: oauth.token?.expiry,
255289
refresh_token: oauth.refreshToken?.value,
@@ -264,23 +298,24 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI {
264298
return true;
265299
}
266300

267-
function isRefreshNeeded(): boolean {
301+
function isRefreshNeeded(profile?: string): boolean {
268302
if (ctx.hasEnvCredentials()) {
269303
return false;
270304
}
271305
const { accessToken } = readStoredAuthState({
272306
warningLogger: ctx.logger,
273-
storage,
307+
storage: getStorage(profile),
274308
});
275309
return Boolean(accessToken && new Date() >= new Date(accessToken.expiry));
276310
}
277311

278-
async function refreshToken(): Promise<boolean> {
312+
async function refreshToken(profile?: string): Promise<boolean> {
279313
// `exchangeRefreshTokenForAccessToken` reads the refresh token fresh from
280314
// disk on every call, so we always pick up the latest rotation written by a
281315
// sibling Wrangler process. Refresh tokens are single-use, so a long-lived
282316
// process such as `wrangler dev` would otherwise send a stale value and get
283317
// a 401 from the token endpoint.
318+
const storage = getStorage(profile);
284319

285320
try {
286321
const {
@@ -323,7 +358,7 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI {
323358
// TODO: ask permission before opening browser
324359
const stored = readStoredAuthState({
325360
warningLogger: ctx.logger,
326-
storage,
361+
storage: getStorage(props.profile),
327362
});
328363
if (!stored.accessToken && !stored.deprecatedApiToken) {
329364
// Not logged in.
@@ -338,10 +373,10 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI {
338373
return { loggedIn: true };
339374
}
340375
return { loggedIn: false, reason: "no-credentials-login-failed" };
341-
} else if (isRefreshNeeded()) {
376+
} else if (isRefreshNeeded(props.profile)) {
342377
// We're logged in, but the refresh token seems to have expired,
343378
// so let's try to refresh it
344-
const didRefresh = await refreshToken();
379+
const didRefresh = await refreshToken(props.profile);
345380
if (didRefresh) {
346381
// The token was refreshed, so we're done here
347382
return { loggedIn: true };
@@ -362,7 +397,7 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI {
362397
}
363398
}
364399

365-
async function logout(): Promise<void> {
400+
async function logout(profile?: string): Promise<void> {
366401
const clearedTemporary = clearTemporaryAccount();
367402

368403
if (ctx.hasEnvCredentials()) {
@@ -376,7 +411,7 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI {
376411

377412
const storedRefreshToken = readStoredAuthState({
378413
warningLogger: ctx.logger,
379-
storage,
414+
storage: getStorage(profile),
380415
}).refreshToken;
381416
if (!storedRefreshToken) {
382417
ctx.logger.log(
@@ -400,14 +435,17 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI {
400435
},
401436
});
402437
await response.text(); // blank text? would be nice if it was something meaningful
403-
storage.clear();
438+
getStorage(profile).clear();
404439
ctx.logger.log(`Successfully logged out.`);
405440
ctx.purgeOnLoginOrLogout?.();
406441
}
407442

408443
async function getOAuthTokenFromLocalState(): Promise<string | undefined> {
409444
// Check if we have an OAuth token
410-
let stored = readStoredAuthState({ warningLogger: ctx.logger, storage });
445+
let stored = readStoredAuthState({
446+
warningLogger: ctx.logger,
447+
storage: getStorage(),
448+
});
411449
if (!stored.accessToken) {
412450
return undefined;
413451
}
@@ -425,7 +463,10 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI {
425463
return undefined;
426464
}
427465
// Re-read after the refresh has persisted the new token to disk.
428-
stored = readStoredAuthState({ warningLogger: ctx.logger, storage });
466+
stored = readStoredAuthState({
467+
warningLogger: ctx.logger,
468+
storage: getStorage(),
469+
});
429470
}
430471

431472
return stored.accessToken?.value;
@@ -437,7 +478,7 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI {
437478
}
438479

439480
return getAPIToken({
440-
storage,
481+
storage: getStorage(),
441482
warningLogger: ctx.logger,
442483
allowGlobalAuthKey: ctx.allowGlobalAuthKey,
443484
});
@@ -449,7 +490,7 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI {
449490
}
450491

451492
return requireApiToken({
452-
storage,
493+
storage: getStorage(),
453494
warningLogger: ctx.logger,
454495
allowGlobalAuthKey: ctx.allowGlobalAuthKey,
455496
});
@@ -492,13 +533,31 @@ export function createOAuthFlow(ctx: OAuthFlowContext): OAuthFlowAPI {
492533
return ctx.temporary?.storage.clear() ?? false;
493534
}
494535

536+
function setProfile(profile: string): void {
537+
activeProfile = profile;
538+
}
539+
540+
function getActiveProfile(): string {
541+
return activeProfile;
542+
}
543+
544+
function getScopes(): string[] | undefined {
545+
return readStoredAuthState({
546+
warningLogger: ctx.logger,
547+
storage: getStorage(),
548+
}).scopes;
549+
}
550+
495551
return {
552+
setProfile,
553+
getActiveProfile,
496554
login,
497555
logout,
498556
loginOrRefreshIfRequired,
499557
getOAuthTokenFromLocalState,
500558
getAPIToken: getAPITokenInternal,
501559
requireApiToken: requireApiTokenInternal,
560+
getScopes,
502561
setTemporaryAllowed,
503562
isTemporaryAllowed,
504563
getActiveTemporaryAccount,

packages/workers-auth/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ export { generateRandomState } from "./generate-random-state";
3737
export { TEMPORARY_TERMS_NOTICE, TEMPORARY_TERMS_PROMPT } from "./temporary";
3838
export { PKCE_CHARSET } from "./pkce";
3939

40+
export { createProfileStore, validateProfileName } from "./profiles";
41+
export type {
42+
DeactivateDirectoryResult,
43+
DirectoryBindingOperations,
44+
DirectoryBindingsStorage,
45+
ProfileConfigOperations,
46+
ProfileStore,
47+
} from "./profiles";
48+
4049
export { readStoredAuthState } from "./state";
4150

4251
export type { TemporaryPreviewAccount } from "./config-file/temporary";

0 commit comments

Comments
 (0)