@youkno/edge-cli 1.21.316 → 1.21.318
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pnp.cjs +70 -69
- package/.pnp.loader.mjs +5 -2
- package/.yarnrc.yml +11 -0
- package/README.md +8 -3
- package/dist/commands/token.js +12 -1
- package/dist/lib/auth.js +162 -12
- package/package.json +1 -1
- package/src/commands/token.ts +26 -3
- package/src/lib/auth.ts +206 -13
- package/.yarn/install-state.gz +0 -0
package/src/lib/auth.ts
CHANGED
|
@@ -38,13 +38,40 @@ const DEFAULT_DEVICE_NAME = "edge-cli";
|
|
|
38
38
|
*/
|
|
39
39
|
const EXCHANGE_SCOPE_SHELL = "shell";
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The app scope is the *absence* of a scope field -- `TokenScope.fromWire` maps blank to `APP` --
|
|
43
|
+
* so an API-only session is requested by omitting it, not by sending a name for it.
|
|
44
|
+
*
|
|
45
|
+
* `null`, never `undefined`: a default parameter fires when an argument is `undefined`, so passing
|
|
46
|
+
* an `undefined` sentinel to `authExchangeFirebaseToken` silently selected the shell default and
|
|
47
|
+
* every "API-only" session came back with the `edge-shell` audience. `f(null)` does not trigger the
|
|
48
|
+
* default.
|
|
49
|
+
*/
|
|
50
|
+
const EXCHANGE_SCOPE_APP = null;
|
|
51
|
+
|
|
52
|
+
type AuthSession = {
|
|
42
53
|
accessToken: string;
|
|
43
54
|
refreshToken?: string;
|
|
44
55
|
accessExpEpoch?: number;
|
|
45
56
|
refreshExpEpoch?: number;
|
|
46
57
|
};
|
|
47
58
|
|
|
59
|
+
type AuthAccount = AuthSession & {
|
|
60
|
+
/**
|
|
61
|
+
* A second session exchanged at login with no scope, so its tokens carry only the `edge-api`
|
|
62
|
+
* audience while the account's main session carries `edge-shell` as well.
|
|
63
|
+
*
|
|
64
|
+
* Two sessions rather than one downgraded token because scope is fixed at exchange time and
|
|
65
|
+
* persisted on `user_session.scope`; refresh re-derives it from the session and ignores the
|
|
66
|
+
* request, so a shell token cannot be narrowed after the fact. Both exchanges reuse the one
|
|
67
|
+
* Firebase token the callback delivered, so this costs an HTTP call rather than a second login.
|
|
68
|
+
*
|
|
69
|
+
* Optional: accounts stored before this existed have no `api` session, and `edge-cli token`
|
|
70
|
+
* says so rather than quietly handing back the shell-capable one.
|
|
71
|
+
*/
|
|
72
|
+
api?: AuthSession;
|
|
73
|
+
};
|
|
74
|
+
|
|
48
75
|
type ScopeData = {
|
|
49
76
|
default?: string;
|
|
50
77
|
accounts: Record<string, AuthAccount>;
|
|
@@ -149,6 +176,26 @@ function decodeJwtPayload(token: string): Record<string, unknown> {
|
|
|
149
176
|
}
|
|
150
177
|
}
|
|
151
178
|
|
|
179
|
+
/**
|
|
180
|
+
* The extra audience `TokenScope.SHELL` stamps. Matched by name rather than by "anything that is
|
|
181
|
+
* not the API audience", because the API audience is server-configurable (`api.jwt.audience`) and
|
|
182
|
+
* the CLI cannot know what it was set to.
|
|
183
|
+
*/
|
|
184
|
+
const SHELL_AUDIENCE = "edge-shell";
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Whether a token can drive the shell.
|
|
188
|
+
*
|
|
189
|
+
* Read from the token rather than inferred from what was requested. Asking for an app-scoped
|
|
190
|
+
* session and checking nothing is how every "API-only" token shipped carrying `edge-shell`: the
|
|
191
|
+
* exchange succeeded, so nothing looked wrong, and the CLI handed out shell credentials for a day.
|
|
192
|
+
*/
|
|
193
|
+
function carriesShellAudience(accessToken: string): boolean {
|
|
194
|
+
const aud = decodeJwtPayload(accessToken).aud;
|
|
195
|
+
const list = Array.isArray(aud) ? aud : typeof aud === "string" ? [aud] : [];
|
|
196
|
+
return list.some((entry) => entry === SHELL_AUDIENCE);
|
|
197
|
+
}
|
|
198
|
+
|
|
152
199
|
function extractEmailFromToken(accessToken: string): string {
|
|
153
200
|
const payload = decodeJwtPayload(accessToken);
|
|
154
201
|
const email = payload.email;
|
|
@@ -185,12 +232,16 @@ async function requestTokenEndpoint(
|
|
|
185
232
|
return (await response.json()) as TokenResponse;
|
|
186
233
|
}
|
|
187
234
|
|
|
188
|
-
async function authExchangeFirebaseToken(
|
|
235
|
+
async function authExchangeFirebaseToken(
|
|
236
|
+
cfg: EffectiveConfig,
|
|
237
|
+
firebaseToken: string,
|
|
238
|
+
scope: string | null = EXCHANGE_SCOPE_SHELL
|
|
239
|
+
): Promise<TokenResponse> {
|
|
189
240
|
const resp = await requestTokenEndpoint(cfg, "exchange", {
|
|
190
241
|
firebaseToken,
|
|
191
242
|
deviceId: authDeviceId(),
|
|
192
243
|
deviceName: DEFAULT_DEVICE_NAME,
|
|
193
|
-
scope:
|
|
244
|
+
...(scope === null ? {} : { scope })
|
|
194
245
|
});
|
|
195
246
|
if (!resp?.accessToken) {
|
|
196
247
|
throw new Error("Token exchange did not return accessToken");
|
|
@@ -261,9 +312,14 @@ export async function logout(cfg: EffectiveConfig, email?: string): Promise<stri
|
|
|
261
312
|
throw new Error(`Account not found for ${cfg.product}/${cfg.env}: ${target}`);
|
|
262
313
|
}
|
|
263
314
|
|
|
264
|
-
|
|
315
|
+
// Both sessions, or logout leaves the API-only one alive on the server after the CLI has
|
|
316
|
+
// forgotten it -- a session nobody can see and nobody will revoke.
|
|
317
|
+
for (const refreshToken of [account.refreshToken, account.api?.refreshToken]) {
|
|
318
|
+
if (!refreshToken) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
265
321
|
try {
|
|
266
|
-
await authLogoutRefreshToken(cfg,
|
|
322
|
+
await authLogoutRefreshToken(cfg, refreshToken);
|
|
267
323
|
} catch {
|
|
268
324
|
// best effort
|
|
269
325
|
}
|
|
@@ -278,21 +334,53 @@ export async function logout(cfg: EffectiveConfig, email?: string): Promise<stri
|
|
|
278
334
|
return target;
|
|
279
335
|
}
|
|
280
336
|
|
|
281
|
-
function
|
|
337
|
+
function toSession(tokenResp: TokenResponse, now: number): AuthSession {
|
|
338
|
+
return {
|
|
339
|
+
accessToken: tokenResp.accessToken,
|
|
340
|
+
refreshToken: tokenResp.refreshToken,
|
|
341
|
+
accessExpEpoch: now + (tokenResp.accessTokenExpiresInSec ?? 0),
|
|
342
|
+
refreshExpEpoch: tokenResp.refreshToken ? now + (tokenResp.refreshTokenExpiresInSec ?? 0) : 0
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* @param apiResp the API-only session, when this call has a fresh one. Omitted on a shell refresh,
|
|
348
|
+
* which must leave any existing `api` session in place -- rewriting the account wholesale would
|
|
349
|
+
* silently drop it and send `edge-cli token` back to "run login again" after every refresh.
|
|
350
|
+
*/
|
|
351
|
+
function storeTokens(
|
|
352
|
+
cfg: EffectiveConfig,
|
|
353
|
+
email: string,
|
|
354
|
+
tokenResp: TokenResponse,
|
|
355
|
+
apiResp?: TokenResponse
|
|
356
|
+
): void {
|
|
282
357
|
const db = readDb();
|
|
283
358
|
const scope = getScope(db, cfg);
|
|
284
359
|
const now = nowEpoch();
|
|
360
|
+
const existingApi = scope.accounts[email]?.api;
|
|
285
361
|
|
|
286
362
|
scope.accounts[email] = {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
accessExpEpoch: now + (tokenResp.accessTokenExpiresInSec ?? 0),
|
|
290
|
-
refreshExpEpoch: tokenResp.refreshToken ? now + (tokenResp.refreshTokenExpiresInSec ?? 0) : 0
|
|
363
|
+
...toSession(tokenResp, now),
|
|
364
|
+
api: apiResp ? toSession(apiResp, now) : existingApi
|
|
291
365
|
};
|
|
292
366
|
scope.default = email;
|
|
293
367
|
writeDb(db);
|
|
294
368
|
}
|
|
295
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Replace only the API session, leaving the shell session untouched.
|
|
372
|
+
*/
|
|
373
|
+
function storeApiTokens(cfg: EffectiveConfig, email: string, apiResp: TokenResponse): void {
|
|
374
|
+
const db = readDb();
|
|
375
|
+
const scope = getScope(db, cfg);
|
|
376
|
+
const account = scope.accounts[email];
|
|
377
|
+
if (!account) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
account.api = toSession(apiResp, nowEpoch());
|
|
381
|
+
writeDb(db);
|
|
382
|
+
}
|
|
383
|
+
|
|
296
384
|
function parseCallbackQuery(queryString: string): {
|
|
297
385
|
email?: string;
|
|
298
386
|
accessToken?: string;
|
|
@@ -464,8 +552,13 @@ export async function login(cfg: EffectiveConfig): Promise<string> {
|
|
|
464
552
|
}
|
|
465
553
|
|
|
466
554
|
let tokenResp: TokenResponse | undefined;
|
|
555
|
+
// Kept so the API-only session can be exchanged from the same credential below. The
|
|
556
|
+
// already-first-party branch leaves it undefined: there is no Firebase token to re-exchange,
|
|
557
|
+
// so that path yields a shell session only.
|
|
558
|
+
let firebaseToken: string | undefined;
|
|
467
559
|
if (parsed.firebaseToken) {
|
|
468
|
-
|
|
560
|
+
firebaseToken = parsed.firebaseToken;
|
|
561
|
+
tokenResp = await authExchangeFirebaseToken(cfg, firebaseToken);
|
|
469
562
|
} else if (parsed.accessToken && parsed.refreshToken) {
|
|
470
563
|
// Already a first-party pair; nothing to exchange.
|
|
471
564
|
tokenResp = {
|
|
@@ -476,7 +569,8 @@ export async function login(cfg: EffectiveConfig): Promise<string> {
|
|
|
476
569
|
};
|
|
477
570
|
} else if (parsed.accessToken) {
|
|
478
571
|
// No refresh token alongside it means this is the console's `?jwt=` Firebase token.
|
|
479
|
-
|
|
572
|
+
firebaseToken = parsed.accessToken;
|
|
573
|
+
tokenResp = await exchangeCallbackToken(cfg, firebaseToken);
|
|
480
574
|
}
|
|
481
575
|
|
|
482
576
|
if (!tokenResp?.accessToken) {
|
|
@@ -484,10 +578,48 @@ export async function login(cfg: EffectiveConfig): Promise<string> {
|
|
|
484
578
|
}
|
|
485
579
|
|
|
486
580
|
const email = parsed.email ?? extractEmailFromToken(tokenResp.accessToken);
|
|
487
|
-
storeTokens(cfg, email, tokenResp);
|
|
581
|
+
storeTokens(cfg, email, tokenResp, await exchangeApiSession(cfg, firebaseToken));
|
|
488
582
|
return email;
|
|
489
583
|
}
|
|
490
584
|
|
|
585
|
+
/**
|
|
586
|
+
* Exchange the same Firebase token a second time for an API-only session.
|
|
587
|
+
*
|
|
588
|
+
* Failure is reported and swallowed rather than failing the login: the shell session -- the reason
|
|
589
|
+
* anyone runs this CLI -- already succeeded by this point, and taking the whole login down over the
|
|
590
|
+
* optional half would turn a working setup into a broken one. `edge-cli token` says what to do when
|
|
591
|
+
* the session is missing.
|
|
592
|
+
*/
|
|
593
|
+
async function exchangeApiSession(
|
|
594
|
+
cfg: EffectiveConfig,
|
|
595
|
+
firebaseToken: string | undefined
|
|
596
|
+
): Promise<TokenResponse | undefined> {
|
|
597
|
+
if (!firebaseToken) {
|
|
598
|
+
return undefined;
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
const resp = await authExchangeFirebaseToken(cfg, firebaseToken, EXCHANGE_SCOPE_APP);
|
|
602
|
+
if (carriesShellAudience(resp.accessToken)) {
|
|
603
|
+
// Storing it would put a shell-capable token behind `edge-cli token`, which is the one
|
|
604
|
+
// outcome this whole feature exists to prevent. Better no API session than a mislabelled
|
|
605
|
+
// one.
|
|
606
|
+
process.stderr.write(
|
|
607
|
+
"Warning: the server returned a shell-scoped token for an app-scoped exchange; " +
|
|
608
|
+
"discarding it.\n'edge-cli token' will need --scope shell until this is resolved.\n"
|
|
609
|
+
);
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
return resp;
|
|
613
|
+
} catch (err) {
|
|
614
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
615
|
+
process.stderr.write(
|
|
616
|
+
`Warning: could not create an API-only session (${reason}).\n` +
|
|
617
|
+
`'edge-cli token' will need --scope shell until the next successful login.\n`
|
|
618
|
+
);
|
|
619
|
+
return undefined;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
491
623
|
async function validateAccessToken(cfg: EffectiveConfig, accessToken: string): Promise<boolean> {
|
|
492
624
|
const url = new URL(`${cfg.baseUrl.replace(/\/$/, "")}/shell`);
|
|
493
625
|
url.searchParams.set("cmd", "help");
|
|
@@ -569,6 +701,67 @@ export async function resolveAccessToken(cfg: EffectiveConfig, forceRefresh = fa
|
|
|
569
701
|
return accessToken;
|
|
570
702
|
}
|
|
571
703
|
|
|
704
|
+
/**
|
|
705
|
+
* The account's API-only access token, refreshed when it is at or near expiry.
|
|
706
|
+
*
|
|
707
|
+
* Deliberately does no liveness probe. `validateAccessToken` asks `GET /shell?cmd=help`, which this
|
|
708
|
+
* token is *supposed* to be refused by -- running it here would read a correctly-scoped token as a
|
|
709
|
+
* broken one and refresh it on every call.
|
|
710
|
+
*
|
|
711
|
+
* A missing API session is an error rather than a silent fall back to the shell token: handing back
|
|
712
|
+
* a token with more audiences than the caller asked for is the same silent substitution the server
|
|
713
|
+
* refuses when it will not downgrade a shell request to APP.
|
|
714
|
+
*/
|
|
715
|
+
export async function resolveApiAccessToken(cfg: EffectiveConfig, forceRefresh = false): Promise<string> {
|
|
716
|
+
const db = readDb();
|
|
717
|
+
const scope = getScope(db, cfg);
|
|
718
|
+
const email = scope.default;
|
|
719
|
+
if (!email) {
|
|
720
|
+
throw new Error(`No JWT session for ${cfg.product}/${cfg.env}. Run 'edge-cli login'.`);
|
|
721
|
+
}
|
|
722
|
+
const account = scope.accounts[email];
|
|
723
|
+
if (!account) {
|
|
724
|
+
throw new Error(`No access token for ${email}. Run 'edge-cli login'.`);
|
|
725
|
+
}
|
|
726
|
+
const api = account.api;
|
|
727
|
+
if (!api?.accessToken) {
|
|
728
|
+
throw new Error(
|
|
729
|
+
`No API-only session for ${email} on ${cfg.product}/${cfg.env}.\n` +
|
|
730
|
+
`Run 'edge-cli login' to create one, or 'edge-cli token --scope shell' to print the ` +
|
|
731
|
+
`shell-capable token instead (it also drives the shell, so do not paste it elsewhere).`
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Checked on read, not only on write, so a session stored by a build that asked for the wrong
|
|
736
|
+
// scope is refused rather than handed out. Those tokens are already on disk in the wild.
|
|
737
|
+
if (carriesShellAudience(api.accessToken)) {
|
|
738
|
+
throw new Error(
|
|
739
|
+
`The stored API session for ${email} carries the ${SHELL_AUDIENCE} audience, so it is ` +
|
|
740
|
+
`not API-only. Run 'edge-cli login' to replace it.`
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const expiry = api.accessExpEpoch ?? 0;
|
|
745
|
+
const stale = expiry > 0 && expiry <= nowEpoch() + 60;
|
|
746
|
+
if ((forceRefresh || stale) && api.refreshToken) {
|
|
747
|
+
try {
|
|
748
|
+
const refreshed = await authRefreshToken(cfg, api.refreshToken);
|
|
749
|
+
const merged: TokenResponse = {
|
|
750
|
+
accessToken: refreshed.accessToken,
|
|
751
|
+
refreshToken: refreshed.refreshToken || api.refreshToken,
|
|
752
|
+
accessTokenExpiresInSec: refreshed.accessTokenExpiresInSec,
|
|
753
|
+
refreshTokenExpiresInSec: refreshed.refreshTokenExpiresInSec
|
|
754
|
+
};
|
|
755
|
+
storeApiTokens(cfg, email, merged);
|
|
756
|
+
return merged.accessToken;
|
|
757
|
+
} catch {
|
|
758
|
+
// Fall through to the stored token; it may still have life left in it.
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return api.accessToken;
|
|
763
|
+
}
|
|
764
|
+
|
|
572
765
|
export async function resolveAuthHeader(cfg: EffectiveConfig): Promise<string> {
|
|
573
766
|
const accessToken = await resolveAccessToken(cfg);
|
|
574
767
|
return `Bearer ${accessToken}`;
|
package/.yarn/install-state.gz
DELETED
|
Binary file
|