@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/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
- type AuthAccount = {
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(cfg: EffectiveConfig, firebaseToken: string): Promise<TokenResponse> {
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: EXCHANGE_SCOPE_SHELL
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
- if (account.refreshToken) {
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, account.refreshToken);
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 storeTokens(cfg: EffectiveConfig, email: string, tokenResp: TokenResponse): void {
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
- accessToken: tokenResp.accessToken,
288
- refreshToken: tokenResp.refreshToken,
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
- tokenResp = await authExchangeFirebaseToken(cfg, parsed.firebaseToken);
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
- tokenResp = await exchangeCallbackToken(cfg, parsed.accessToken);
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}`;
Binary file