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/dist/appmesh.esm.js +1 -1
- package/package.json +8 -1
- package/src/appmesh.js +290 -38
- package/src/appmesh_oauth.js +367 -0
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`
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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(); //
|
|
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
|
-
*
|
|
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 =
|
|
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 (
|
|
585
|
-
|
|
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
|
-
|
|
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
|
/**
|