appmesh 1.3.2 → 1.3.3

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/appmesh.js CHANGED
@@ -18,6 +18,16 @@ const CONSTANTS = Object.freeze({
18
18
  HTTP_HEADER_KEY_AUTH: "Authorization",
19
19
  HTTP_HEADER_KEY_X_TARGET_HOST: "X-Target-Host",
20
20
  HTTP_HEADER_KEY_X_FILE_PATH: "X-File-Path",
21
+
22
+ // Auto-refresh pacing: the loop wakes every TOKEN_REFRESH_POLL_SECONDS but renews only
23
+ // once the token has burned TOKEN_REFRESH_LIFETIME_RATIO of its own lifetime.
24
+ TOKEN_REFRESH_POLL_SECONDS: 300, // poll cap, NOT a renew interval
25
+ TOKEN_REFRESH_OFFSET_SECONDS: 30, // floor for the pre-expiry margin
26
+ TOKEN_REFRESH_LIFETIME_RATIO: 0.6, // rest is the retry budget
27
+ TOKEN_REFRESH_JITTER_RATIO: 0.1, // of the margin, so clients don't renew in lockstep
28
+ TOKEN_REFRESH_RETRY_BASE_SECONDS: 5,
29
+ TOKEN_REFRESH_RETRY_MAX_SECONDS: 60,
30
+ TOKEN_REFRESH_LOG_EVERY: 10, // log the 1st failure, then every Nth
21
31
  });
22
32
 
23
33
  // Environment detection
@@ -248,22 +258,39 @@ async function _resolveGid(group) {
248
258
  }
249
259
 
250
260
  /**
251
- * Decode the `exp` field from a JWT without signature verification.
261
+ * Decode the `exp` and `iat` claims from a JWT without signature verification.
252
262
  * @param {string} token - JWT string (header.payload.signature)
253
- * @returns {number|null} Expiry timestamp in seconds, or null
263
+ * @returns {{exp: number, iat: number}|null} Claims (`iat` is 0 when absent), or null when
264
+ * the token is not a decodable JWT carrying `exp`
254
265
  * @private
255
266
  */
256
- function _decodeJwtExp(token) {
267
+ function _decodeJwtTimes(token) {
257
268
  try {
258
269
  const parts = token.split('.');
259
270
  if (parts.length !== 3) return null;
260
271
  const payload = JSON.parse(base64Utils.decode(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
261
- return typeof payload.exp === 'number' ? payload.exp : null;
272
+ if (typeof payload.exp !== 'number') return null;
273
+ return { exp: payload.exp, iat: typeof payload.iat === 'number' ? payload.iat : 0 };
262
274
  } catch (_) {
263
275
  return null;
264
276
  }
265
277
  }
266
278
 
279
+ /**
280
+ * FNV-1a 32-bit hash, matching the Go SDK so both derive the same jitter from a token.
281
+ * @param {string} str - Input string (JWTs are ASCII, so code units are bytes)
282
+ * @returns {number} Unsigned 32-bit hash
283
+ * @private
284
+ */
285
+ function _fnv1a32(str) {
286
+ let hash = 0x811c9dc5;
287
+ for (let i = 0; i < str.length; i++) {
288
+ hash ^= str.charCodeAt(i);
289
+ hash = Math.imul(hash, 0x01000193);
290
+ }
291
+ return hash >>> 0;
292
+ }
293
+
267
294
  // Default App Mesh CA bundle, preferred when no sslConfig is given and the file exists
268
295
  const DEFAULT_CA_FILE = '/opt/appmesh/ssl/ca.pem';
269
296
 
@@ -300,6 +327,8 @@ class AppMeshClient {
300
327
  * against the App Mesh default CA (/opt/appmesh/ssl/ca.pem) if installed, else the system CAs;
301
328
  * `false` disables verification; an object supplies custom `ca`/`cert`/`key` (Buffer, inline
302
329
  * PEM, or file path — a missing path is a hard error).
330
+ * @param {boolean|null} [useRefreshToken=null] - Ask the daemon for a refresh token
331
+ * (see {@link AppMeshClient#set_use_refresh_token} for the tri-state and the browser default).
303
332
  * @example
304
333
  * const sslConfig = {
305
334
  * cert: fs.readFileSync("client.pem"),
@@ -308,7 +337,7 @@ class AppMeshClient {
308
337
  * rejectUnauthorized: true
309
338
  * };
310
339
  */
311
- constructor(baseURL = ENV.isNode ? 'https://127.0.0.1:6060' : window.location.origin, sslConfig = null) {
340
+ constructor(baseURL = ENV.isNode ? 'https://127.0.0.1:6060' : window.location.origin, sslConfig = null, useRefreshToken = null) {
312
341
  // Base URL for API requests
313
342
  this.baseURL = baseURL;
314
343
 
@@ -319,6 +348,34 @@ class AppMeshClient {
319
348
  // sync from it via _handleTokenUpdate/_getAccessToken)
320
349
  this._token = null;
321
350
 
351
+ // Refresh token from the login/totp-validate/renew response body; issued by both
352
+ // Keycloak and local-JWT daemons, absent against an older daemon that returns none.
353
+ this._refreshToken = null;
354
+
355
+ // Expire seconds from login, replayed on renew so the caller's TTL is kept.
356
+ this._tokenExpireSeconds = null;
357
+
358
+ // {exp, iat} as reported by the daemon in the login/renew body. The only lifetime
359
+ // source available in a browser, where the auth cookie is HttpOnly and unreadable.
360
+ this._tokenTimes = null;
361
+
362
+ // Per-client jitter seed, used only when no token is readable: the session times
363
+ // alone are identical for every client that logs in during the same second.
364
+ this._jitterSeed = Math.random().toString(36).slice(2);
365
+
366
+ // Bumped by logout() so a response from a superseded session cannot revive it.
367
+ this._sessionEpoch = 0;
368
+
369
+ // Auto-refresh state: pending timer, consecutive renewal failures (drives the
370
+ // backoff), and the tail of the renewal queue that serializes renew_token().
371
+ this._autoRefreshEnabled = false;
372
+ this._autoRefreshJwt = null;
373
+ // Tri-state opt-in for being ISSUED a refresh token; null follows auto-refresh.
374
+ this._useRefreshToken = useRefreshToken;
375
+ this._refreshTimer = null;
376
+ this._refreshFailures = 0;
377
+ this._renewChain = null;
378
+
322
379
  // Configure axios instance
323
380
  const axiosConfig = {
324
381
  baseURL,
@@ -397,11 +454,15 @@ class AppMeshClient {
397
454
  [CONSTANTS.HTTP_HEADER_KEY_AUTH]: `Basic ${auth}`,
398
455
  "X-Set-Cookie": "true"
399
456
  };
457
+ // Omitted, not "false", when declined: the daemon only issues on an explicit opt-in.
458
+ if (this._wantRefreshToken()) headers["X-Refresh-Token-Request"] = "true";
400
459
  if (totpCode) headers["X-Totp-Code"] = totpCode;
401
460
  if (tokenExpire) headers["X-Expire-Seconds"] = parseDuration(tokenExpire);
402
461
  if (audience) headers["X-Audience"] = audience;
403
462
 
404
- await this._request("post", "/appmesh/login", null, { headers });
463
+ const response = await this._request("post", "/appmesh/login", null, { headers });
464
+ this._captureRefreshToken(response);
465
+ this._tokenExpireSeconds = tokenExpire ? parseDuration(tokenExpire) : null;
405
466
  }
406
467
 
407
468
  /**
@@ -422,6 +483,12 @@ class AppMeshClient {
422
483
  if (audience) headers["X-Audience"] = audience;
423
484
  if (updateSession) headers["X-Set-Cookie"] = "true";
424
485
  const response = await this._request("post", "/appmesh/auth", null, { headers });
486
+ if (updateSession) {
487
+ // /appmesh/auth returns the same body as login. In a browser this is the only way to
488
+ // learn the lifetime — set_token() cannot replace an HttpOnly cookie — so without it
489
+ // auto-refresh would idle at the poll interval until the session silently died.
490
+ this._captureRefreshToken(response);
491
+ }
425
492
  const responseText = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
426
493
  return { success: true, responseText };
427
494
  }
@@ -479,12 +546,40 @@ class AppMeshClient {
479
546
  }
480
547
  }
481
548
 
549
+ /**
550
+ * Store the refresh token carried by a login/totp-validate/renew response body.
551
+ * Both modes rotate it on renew, so a present value replaces the stored one and an
552
+ * absent value keeps it.
553
+ * @param {Object} response - Response object from _request
554
+ * @private
555
+ */
556
+ _captureRefreshToken(response, epoch = this._sessionEpoch) {
557
+ if (epoch !== this._sessionEpoch) return; // superseded by a logout
558
+ let data = response && response.data;
559
+ if (typeof data === 'string') {
560
+ try { data = JSON.parse(data); } catch (_) { return; }
561
+ }
562
+ if (!data) return;
563
+ if (typeof data.refresh_token === 'string' && data.refresh_token) {
564
+ this._refreshToken = data.refresh_token;
565
+ }
566
+ // The daemon reports the lifetime in the response body, which stays readable even when
567
+ // the token itself is an HttpOnly cookie. That is what lets a browser pace itself off
568
+ // the real lifetime instead of falling back to a fixed cadence.
569
+ if (Number.isFinite(data.expire_time) && Number.isFinite(data.issued_at)) {
570
+ this._tokenTimes = { exp: data.expire_time, iat: data.issued_at };
571
+ }
572
+ }
573
+
482
574
  /**
483
575
  * Logout from the current session.
484
576
  */
485
577
  async logout() {
486
578
  try {
487
- await this._request("post", "/appmesh/self/logoff");
579
+ // OAuth2 (Keycloak) proxy mode: present the refresh token so the daemon can end the
580
+ // upstream session. Optional — logoff still succeeds (local revoke) without it.
581
+ const headers = this._refreshToken ? { "X-Refresh-Token": this._refreshToken } : {};
582
+ await this._request("post", "/appmesh/self/logoff", null, { headers });
488
583
  } catch (error) {
489
584
  console.error("Failed to logoff:", error.message);
490
585
  } finally {
@@ -494,7 +589,13 @@ class AppMeshClient {
494
589
  }
495
590
 
496
591
  this._stopAutoRefresh();
592
+ // Supersede any renewal already in flight: its response would otherwise land after
593
+ // this block and re-populate the token, the refresh token and the times — leaving a
594
+ // logged-out client holding a credential pair the daemon minted after logoff.
595
+ this._sessionEpoch++;
497
596
  this._token = null;
597
+ this._refreshToken = null;
598
+ this._tokenTimes = null;
498
599
 
499
600
  // Clear the outgoing auth cookie (Node); the browser's HttpOnly auth cookie is cleared
500
601
  // server-side on logoff.
@@ -518,15 +619,129 @@ class AppMeshClient {
518
619
  }
519
620
  }
520
621
 
622
+ /**
623
+ * Choose whether login/totp-validate/renew ask the daemon to issue a refresh token.
624
+ *
625
+ * A refresh token is a long-lived credential (TTL `max(2*access, access+1h)`), so a one-shot
626
+ * script that logs in, makes a call and exits would be handed a multi-day credential it
627
+ * neither stores nor revokes. Hence the caller decides.
628
+ *
629
+ * @param {boolean|null} [value=null] - `true`/`false` force the choice; `null` (default) follows
630
+ * auto-refresh, which is already the "is this client long-lived?" signal — except in a browser.
631
+ *
632
+ * Browser asymmetry: the resolved default is `false` even with auto-refresh on. The auth cookie is
633
+ * HttpOnly precisely so XSS cannot reach it; a refresh token held in JS memory would hand that back
634
+ * to any injected script, and for far longer. Nothing in the browser needs it either — pacing reads
635
+ * `expire_time`/`issued_at` from the response body and renewal goes through the cookie. An explicit
636
+ * `true` is still honoured.
637
+ */
638
+ set_use_refresh_token(value = null) {
639
+ this._useRefreshToken = value;
640
+ }
641
+
642
+ /**
643
+ * Resolve the refresh-token tri-state for the outgoing request.
644
+ * @returns {boolean} true to send the opt-in header; false to omit it entirely
645
+ * @private
646
+ */
647
+ _wantRefreshToken() {
648
+ if (typeof this._useRefreshToken === 'boolean') return this._useRefreshToken;
649
+ return ENV.isNode && this._autoRefreshEnabled;
650
+ }
651
+
521
652
  /** @private */
522
653
  _stopAutoRefresh() {
523
654
  this._autoRefreshEnabled = false;
655
+ this._refreshFailures = 0;
524
656
  if (this._refreshTimer) {
525
657
  clearTimeout(this._refreshTimer);
526
658
  this._refreshTimer = null;
527
659
  }
528
660
  }
529
661
 
662
+ /**
663
+ * Seconds before expiry at which to renew: a fraction of the token's own lifetime,
664
+ * floored at TOKEN_REFRESH_OFFSET_SECONDS.
665
+ * @param {string} token - JWT the margin is derived from (also seeds the jitter)
666
+ * @param {number} exp - Expiry claim, epoch seconds
667
+ * @param {number} iat - Issued-at claim, epoch seconds; 0 when absent
668
+ * @returns {number} Margin in seconds
669
+ * @private
670
+ */
671
+ static _refreshMargin(token, exp, iat) {
672
+ const lifetime = (iat > 0 && exp > iat) ? exp - iat : exp - Date.now() / 1000;
673
+ let margin = Math.max(lifetime * (1 - CONSTANTS.TOKEN_REFRESH_LIFETIME_RATIO), CONSTANTS.TOKEN_REFRESH_OFFSET_SECONDS);
674
+
675
+ // Jitter derived from the token: stable across polls, distinct per client.
676
+ const spread = margin * CONSTANTS.TOKEN_REFRESH_JITTER_RATIO;
677
+ margin += ((_fnv1a32(token) % 2001) / 1000 - 1) * spread;
678
+
679
+ // Clamp last: the 30s floor (and its jitter) must never exceed the token's own life,
680
+ // or every renewal would land past the refresh point and the loop would spin at ~1Hz.
681
+ return lifetime > 0 ? Math.min(margin, lifetime / 2) : margin;
682
+ }
683
+
684
+ /**
685
+ * Bounded exponential backoff for the nth consecutive renewal failure (n >= 1).
686
+ * @param {number} failures - Consecutive failure count
687
+ * @returns {number} Delay in seconds
688
+ * @private
689
+ */
690
+ static _refreshRetryDelay(failures) {
691
+ const shift = Math.min(Math.max(failures - 1, 0), 16);
692
+ return Math.min(CONSTANTS.TOKEN_REFRESH_RETRY_BASE_SECONDS * Math.pow(2, shift), CONSTANTS.TOKEN_REFRESH_RETRY_MAX_SECONDS);
693
+ }
694
+
695
+ /**
696
+ * Decide how long to sleep and whether a renewal is due when that sleep ends.
697
+ * The sleep is capped at the poll interval so a token replaced elsewhere is noticed;
698
+ * waking early is not by itself a reason to renew.
699
+ * @returns {{delaySec: number, due: boolean}}
700
+ * @private
701
+ */
702
+ _computeRefreshPlan() {
703
+ const poll = CONSTANTS.TOKEN_REFRESH_POLL_SECONDS;
704
+
705
+ const token = this._autoRefreshJwt || this._getAccessToken();
706
+ const tokenTimes = token ? _decodeJwtTimes(token) : null;
707
+
708
+ // In a browser the auth cookie is HttpOnly, so the token is never readable here. Pace
709
+ // off the lifetime the daemon reported in the login/renew body instead — same 60% rule
710
+ // as every other SDK, rather than the fixed cadence this change exists to remove.
711
+ //
712
+ // Prefer whichever source expires later. _autoRefreshJwt is set once by
713
+ // set_auto_refresh_token and never updated in a browser (_handleTokenUpdate is
714
+ // Node-only), so a frozen copy would otherwise shadow the times every renewal
715
+ // refreshes — and once it aged past its refresh point the loop would renew once per
716
+ // second forever, which is far worse than the cadence this replaced.
717
+ const times = this._tokenTimes && (!tokenTimes || this._tokenTimes.exp > tokenTimes.exp)
718
+ ? this._tokenTimes
719
+ : tokenTimes;
720
+
721
+ if (!token && !times) {
722
+ // A held refresh token can still mint a new access token, so an access token lost
723
+ // to an expired cookie is recoverable — but only if we actually try.
724
+ if (this._refreshToken) return { delaySec: 1, due: true };
725
+ return { delaySec: poll, due: false }; // nothing to renew, just idle
726
+ }
727
+ if (!times) return { delaySec: poll, due: true }; // unreadable lifetime: fixed cadence
728
+
729
+ const nowSec = Date.now() / 1000;
730
+ // Times saying the session is already dead cannot be acted on once per second: a
731
+ // successful renewal would have replaced them, so they are stale rather than urgent.
732
+ // Fall back to the fixed cadence, which bounds the worst case at the poll interval.
733
+ if (times.exp <= nowSec) return { delaySec: poll, due: true };
734
+
735
+ // Jitter needs a per-client seed; without a readable token the session times alone
736
+ // would collide for every client that logged in during the same second, so mix in
737
+ // this client's own random seed.
738
+ const seed = token || `${this._jitterSeed}.${times.iat}.${times.exp}`;
739
+ const wait = times.exp - AppMeshClient._refreshMargin(seed, times.exp, times.iat) - nowSec;
740
+ if (wait <= 0) return { delaySec: 1, due: true }; // at or past the refresh point
741
+ if (wait > poll) return { delaySec: poll, due: false }; // not due; wake only to re-evaluate
742
+ return { delaySec: wait, due: true };
743
+ }
744
+
530
745
  /** @private */
531
746
  _scheduleTokenRefresh() {
532
747
  if (!this._autoRefreshEnabled) return;
@@ -537,36 +752,34 @@ class AppMeshClient {
537
752
  this._refreshTimer = null;
538
753
  }
539
754
 
540
- const REFRESH_INTERVAL = 300; // 5 min default check
541
- const REFRESH_MARGIN = 30; // refresh 30s before expiry
542
-
543
- let delaySec = REFRESH_INTERVAL;
544
-
545
- // Try to compute precise delay from JWT exp
546
- const token = this._autoRefreshJwt || this._getAccessToken();
547
- if (token) {
548
- const exp = _decodeJwtExp(token);
549
- if (exp) {
550
- const now = Math.floor(Date.now() / 1000);
551
- const timeToExpiry = exp - now;
552
- if (timeToExpiry <= REFRESH_MARGIN) {
553
- delaySec = 1; // almost expired, refresh immediately
554
- } else {
555
- delaySec = Math.min(timeToExpiry - REFRESH_MARGIN, REFRESH_INTERVAL);
556
- }
557
- }
755
+ let { delaySec, due } = this._computeRefreshPlan();
756
+ if (this._refreshFailures > 0) {
757
+ // Retry on the backoff schedule, not the stale plan.
758
+ delaySec = AppMeshClient._refreshRetryDelay(this._refreshFailures);
759
+ due = true;
558
760
  }
559
761
 
560
762
  this._refreshTimer = setTimeout(async () => {
763
+ this._refreshTimer = null;
561
764
  if (!this._autoRefreshEnabled) return;
562
- try {
563
- // _request captures the renewed token cookie and routes it through
564
- // _handleTokenUpdate, which updates _autoRefreshJwt for precise delays
565
- await this.renew_token();
566
- } catch (err) {
567
- console.warn("Auto-refresh: token renewal failed:", err.message);
765
+ if (due) {
766
+ try {
767
+ // _request captures the renewed token cookie and routes it through
768
+ // _handleTokenUpdate, which updates _autoRefreshJwt for precise delays
769
+ await this.renew_token();
770
+ if (this._refreshFailures > 0) {
771
+ console.info(`Auto-refresh: token renewal recovered after ${this._refreshFailures} failure(s)`);
772
+ }
773
+ this._refreshFailures = 0;
774
+ } catch (err) {
775
+ this._refreshFailures++;
776
+ // Log sparsely: a daemon outage must not flood at the backoff rate.
777
+ if (this._refreshFailures === 1 || this._refreshFailures % CONSTANTS.TOKEN_REFRESH_LOG_EVERY === 0) {
778
+ console.warn(`Auto-refresh: token renewal failed (attempt ${this._refreshFailures}):`, err.message);
779
+ }
780
+ }
568
781
  }
569
- this._scheduleTokenRefresh(); // re-schedule
782
+ this._scheduleTokenRefresh(); // keep the loop armed, failure or not
570
783
  }, delaySec * 1000);
571
784
 
572
785
  // Don't block Node.js process exit
@@ -577,14 +790,48 @@ class AppMeshClient {
577
790
 
578
791
  /**
579
792
  * Renew the current JWT token.
580
- * @param {string|number} [tokenExpire] - Token expiry (integer seconds or ISO 8601 string)
793
+ *
794
+ * A held refresh token is the sole credential the daemon needs, so renewal succeeds even
795
+ * after the access token expired — that is how a missed refresh window recovers without a
796
+ * re-login. Without one, the daemon authenticates the access token instead.
797
+ *
798
+ * @param {string|number} [tokenExpire] - Token expiry (integer seconds or ISO 8601 string).
799
+ * Defaults to the value used at login; the header is omitted when that is unknown, so the
800
+ * daemon applies its own default rather than silently extending a short-lived token.
581
801
  */
582
- async renew_token(tokenExpire = CONSTANTS.DEFAULT_TOKEN_EXPIRE_SECONDS) {
802
+ async renew_token(tokenExpire = null) {
803
+ // Serialize renewals: rotation makes a refresh token single-use, so two concurrent
804
+ // renewals present the same one and the loser is told it is revoked — permanently
805
+ // wedging a client that holds no other credential. (See also the AppMeshWorker
806
+ // constructor docs on sharing one client instead of running two renew loops.)
807
+ const run = (this._renewChain || Promise.resolve()).then(() => this._renewTokenOnce(tokenExpire));
808
+ this._renewChain = run.catch(() => { /* a failed renewal must not break the queue */ });
809
+ return run;
810
+ }
811
+
812
+ /** @private */
813
+ async _renewTokenOnce(tokenExpire) {
814
+ const expire = (tokenExpire === null || tokenExpire === undefined) ? this._tokenExpireSeconds : tokenExpire;
583
815
  const headers = {};
584
- if (tokenExpire) {
585
- headers["X-Expire-Seconds"] = parseDuration(tokenExpire);
816
+ if (this._wantRefreshToken()) headers["X-Refresh-Token-Request"] = "true";
817
+ if (expire !== null && expire !== undefined) {
818
+ headers["X-Expire-Seconds"] = parseDuration(expire);
819
+ }
820
+ if (this._refreshToken) {
821
+ headers["X-Refresh-Token"] = this._refreshToken;
822
+ }
823
+
824
+ let response;
825
+ try {
826
+ response = await this._request("post", "/appmesh/token/renew", null, { headers });
827
+ } catch (error) {
828
+ // A rejected refresh token will never be accepted again (rotated away, revoked, or the
829
+ // session ended). Drop it so the next attempt presents the access token instead.
830
+ if (error && error.statusCode === 401) this._refreshToken = null;
831
+ throw error;
586
832
  }
587
- await this._request("post", "/appmesh/token/renew", null, { headers });
833
+ // Both modes rotate the refresh token on renew — store the new one for the next cycle.
834
+ this._captureRefreshToken(response);
588
835
  }
589
836
 
590
837
  /**
@@ -621,8 +868,13 @@ class AppMeshClient {
621
868
  };
622
869
  // Set cookie header for browser
623
870
  const headers = { "X-Set-Cookie": "true" };
871
+ if (this._wantRefreshToken()) headers["X-Refresh-Token-Request"] = "true";
624
872
 
625
- await this._request("post", "/appmesh/totp/validate", body, { headers });
873
+ const response = await this._request("post", "/appmesh/totp/validate", body, { headers });
874
+ // A validated challenge completes the login, so it owes the same session setup as
875
+ // login(); auto-refresh itself is (re)armed by _handleTokenUpdate when the token lands.
876
+ this._captureRefreshToken(response);
877
+ this._tokenExpireSeconds = tokenExpire ? parseDuration(tokenExpire) : null;
626
878
  }
627
879
 
628
880
  /**