appmesh 1.3.3 → 3.0.0

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
@@ -10,24 +10,11 @@ let _httpsReady = null; // Promise that resolves when https/fs are loaded
10
10
  const CONSTANTS = Object.freeze({
11
11
  HTTP_USER_AGENT_HEADER_NAME: "User-Agent",
12
12
  HTTP_USER_AGENT: "appmesh/javascript",
13
- HTTP_STATUS_PRECONDITION_REQUIRED: 428,
14
- DEFAULT_TOKEN_EXPIRE_SECONDS: "P1W",
15
13
  DEFAULT_RUN_APP_TIMEOUT_SECONDS: "P2D",
16
14
  DEFAULT_RUN_APP_LIFECYCLE_SECONDS: "P2DT12H",
17
- DEFAULT_JWT_AUDIENCE: "appmesh-service",
18
15
  HTTP_HEADER_KEY_AUTH: "Authorization",
19
16
  HTTP_HEADER_KEY_X_TARGET_HOST: "X-Target-Host",
20
17
  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
31
18
  });
32
19
 
33
20
  // Environment detection
@@ -35,15 +22,6 @@ const ENV = Object.freeze({
35
22
  isNode: !(typeof window !== 'undefined' && typeof window.document !== 'undefined')
36
23
  });
37
24
 
38
- // Base64 utilities for Node.js and browser
39
- const base64Utils = ENV.isNode ? {
40
- encode: str => Buffer.from(str).toString('base64'),
41
- decode: str => Buffer.from(str, 'base64').toString()
42
- } : {
43
- encode: str => btoa(str),
44
- decode: str => atob(str)
45
- };
46
-
47
25
  /**
48
26
  * Custom error for AppMesh with enhanced debugging
49
27
  */
@@ -65,32 +43,6 @@ class AppMeshError extends Error {
65
43
  }
66
44
  }
67
45
 
68
- /**
69
- * Error thrown when the server requires a TOTP code to complete login (HTTP 428).
70
- *
71
- * Subclasses AppMeshError with `statusCode` fixed to 428, so existing catch-based
72
- * callers keep working. Carries the server-issued challenge as `totpChallenge`,
73
- * ready to pass to `validate_totp(username, totpChallenge, totpCode)`.
74
- */
75
- class TotpRequiredError extends AppMeshError {
76
- /**
77
- * @param {string} message - Error message
78
- * @param {any} responseData - Raw 428 response body (object or JSON string containing `totp_challenge`)
79
- */
80
- constructor(message, responseData = null) {
81
- super(message, 428, responseData, 'TOTP_REQUIRED');
82
- this.name = 'TotpRequiredError';
83
- let data = responseData;
84
- if (typeof data === 'string') {
85
- try { data = JSON.parse(data); } catch (_) { data = null; }
86
- }
87
- /** @type {string|null} Server-issued TOTP challenge for validate_totp() */
88
- this.totpChallenge = (data && typeof data === 'object' && typeof data.totp_challenge === 'string')
89
- ? data.totp_challenge
90
- : null;
91
- }
92
- }
93
-
94
46
  /**
95
47
  * App removed before its async-run exit was observed. Typed replacement for the
96
48
  * old -1 sentinel, keeping real (possibly negative) exit codes unambiguous.
@@ -257,40 +209,6 @@ async function _resolveGid(group) {
257
209
  }
258
210
  }
259
211
 
260
- /**
261
- * Decode the `exp` and `iat` claims from a JWT without signature verification.
262
- * @param {string} token - JWT string (header.payload.signature)
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`
265
- * @private
266
- */
267
- function _decodeJwtTimes(token) {
268
- try {
269
- const parts = token.split('.');
270
- if (parts.length !== 3) return null;
271
- const payload = JSON.parse(base64Utils.decode(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
272
- if (typeof payload.exp !== 'number') return null;
273
- return { exp: payload.exp, iat: typeof payload.iat === 'number' ? payload.iat : 0 };
274
- } catch (_) {
275
- return null;
276
- }
277
- }
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
-
294
212
  // Default App Mesh CA bundle, preferred when no sslConfig is given and the file exists
295
213
  const DEFAULT_CA_FILE = '/opt/appmesh/ssl/ca.pem';
296
214
 
@@ -327,8 +245,6 @@ class AppMeshClient {
327
245
  * against the App Mesh default CA (/opt/appmesh/ssl/ca.pem) if installed, else the system CAs;
328
246
  * `false` disables verification; an object supplies custom `ca`/`cert`/`key` (Buffer, inline
329
247
  * 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).
332
248
  * @example
333
249
  * const sslConfig = {
334
250
  * cert: fs.readFileSync("client.pem"),
@@ -337,7 +253,7 @@ class AppMeshClient {
337
253
  * rejectUnauthorized: true
338
254
  * };
339
255
  */
340
- constructor(baseURL = ENV.isNode ? 'https://127.0.0.1:6060' : window.location.origin, sslConfig = null, useRefreshToken = null) {
256
+ constructor(baseURL = ENV.isNode ? 'https://127.0.0.1:6060' : window.location.origin, sslConfig = null) {
341
257
  // Base URL for API requests
342
258
  this.baseURL = baseURL;
343
259
 
@@ -348,34 +264,6 @@ class AppMeshClient {
348
264
  // sync from it via _handleTokenUpdate/_getAccessToken)
349
265
  this._token = null;
350
266
 
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
-
379
267
  // Configure axios instance
380
268
  const axiosConfig = {
381
269
  baseURL,
@@ -421,470 +309,40 @@ class AppMeshClient {
421
309
  );
422
310
  }
423
311
 
424
- /**
425
- * Login with username/password and let the server attach the session token cookie.
426
- * @param {string} username
427
- * @param {string} password
428
- * @param {string} [totpCode] - TOTP code if 2FA is enabled
429
- * @param {string|number} [tokenExpire] - Token expiry (integer seconds or ISO 8601 string)
430
- * @param {string} [audience] - JWT audience
431
- * @returns {Promise<void>} Resolves when login succeeds.
432
- * @throws {TotpRequiredError} When the server requires TOTP (HTTP 428) and no valid code was
433
- * supplied. Catch it, read `error.totpChallenge`, then complete the login with
434
- * `validate_totp(username, error.totpChallenge, totpCode)`:
435
- * @example
436
- * try {
437
- * await client.login(user, pass);
438
- * } catch (err) {
439
- * if (err instanceof TotpRequiredError) {
440
- * await client.validate_totp(user, err.totpChallenge, totpCode);
441
- * } else {
442
- * throw err;
443
- * }
444
- * }
445
- */
446
- async login(username, password, totpCode = null, tokenExpire = CONSTANTS.DEFAULT_TOKEN_EXPIRE_SECONDS, audience = CONSTANTS.DEFAULT_JWT_AUDIENCE) {
447
- // Validate inputs
448
- if (!username || !password) {
449
- throw new AppMeshError('Username and password are required', 400, null, 'INVALID_CREDENTIALS');
450
- }
451
-
452
- const auth = base64Utils.encode(`${username}:${password}`);
453
- const headers = {
454
- [CONSTANTS.HTTP_HEADER_KEY_AUTH]: `Basic ${auth}`,
455
- "X-Set-Cookie": "true"
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";
459
- if (totpCode) headers["X-Totp-Code"] = totpCode;
460
- if (tokenExpire) headers["X-Expire-Seconds"] = parseDuration(tokenExpire);
461
- if (audience) headers["X-Audience"] = audience;
462
-
463
- const response = await this._request("post", "/appmesh/login", null, { headers });
464
- this._captureRefreshToken(response);
465
- this._tokenExpireSeconds = tokenExpire ? parseDuration(tokenExpire) : null;
312
+ /** Attach a caller-owned Dex access token in memory. */
313
+ set_bearer_token(token) {
314
+ const value = typeof token === 'string' ? token.trim() : '';
315
+ this._handleTokenUpdate(value || null);
466
316
  }
467
317
 
468
- /**
469
- * Verify an external JWT token and optionally update this client session.
470
- *
471
- * @param {string} token - JWT token to verify
472
- * @param {string} [permission=null] - Permission to check
473
- * @param {string} [audience] - JWT audience
474
- * @param {boolean} [updateSession=true] - When true, updates this client session with the
475
- * verified token and persists local auth state on success. When false, the token is only
476
- * verified and local state is unchanged.
477
- * @returns {Promise<{success: boolean, responseText: string}>} Verification result (success is always true).
478
- * @throws {AppMeshError} If verification fails (invalid token, permission or audience mismatch, network error).
479
- */
480
- async authenticate(token, permission = null, audience = CONSTANTS.DEFAULT_JWT_AUDIENCE, updateSession = true) {
481
- const headers = { Authorization: `Bearer ${token}` };
482
- if (permission) headers["X-Permission"] = permission;
483
- if (audience) headers["X-Audience"] = audience;
484
- if (updateSession) headers["X-Set-Cookie"] = "true";
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
- }
492
- const responseText = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
493
- return { success: true, responseText };
318
+ /** Source-compatible alias for set_bearer_token(). */
319
+ set_token(token) {
320
+ this.set_bearer_token(token);
494
321
  }
495
322
 
496
- /**
497
- * Set a JWT token directly without server-side verification.
498
- * Use when the token is already known to be valid.
499
- * For server-side verification, use authenticate() instead.
500
- * @param {string} token - A valid JWT token string. In Node.js this updates the outgoing Cookie
501
- * header; in browsers it cannot replace the HttpOnly auth cookie.
502
- */
503
- set_token(token) {
504
- if (ENV.isNode) {
505
- this._handleTokenUpdate(token);
506
- } else {
507
- // Browser: auth token is HttpOnly (set by server via Set-Cookie),
508
- // document.cookie cannot access or override HttpOnly cookies.
509
- // Use authenticate() for browser-based token verification instead.
510
- console.warn('set_token() is not supported in browser mode (auth cookie is HttpOnly). Use authenticate() instead.');
511
- }
323
+ /** Remove the locally attached bearer without contacting Engine or Dex. */
324
+ clear_bearer_token() {
325
+ this._handleTokenUpdate(null);
512
326
  }
513
327
 
514
- /**
515
- * Get the current access token known to this client.
516
- * @returns {string|null} Current JWT token or null
517
- * @protected
518
- */
328
+ /** Return the current in-memory Dex access token. */
519
329
  _getAccessToken() {
520
330
  return this._token || null;
521
331
  }
522
332
 
523
- /**
524
- * Store a new access token and propagate it to the transport layer.
525
- * Called after login/renew/authenticate responses and by set_token().
526
- * Base implementation keeps the token in memory, syncs the Node.js outgoing
527
- * Cookie header, and reschedules auto-refresh when enabled.
528
- * Subclasses using other transports (e.g. TCP) override this to sync their own store.
529
- * @param {string|null} token - New JWT token (falsy clears the in-memory token)
530
- * @protected
531
- */
333
+ /** Store only the in-memory access token; never synthesize a Cookie header. */
532
334
  _handleTokenUpdate(token) {
533
- const COOKIE_NAME = 'appmesh_auth_token';
534
335
  this._token = token || null;
535
- if (ENV.isNode && this._token) {
536
- const existingCookies = this._client.defaults.headers.Cookie || '';
537
- const cookies = existingCookies.split('; ').filter(c =>
538
- c && !c.startsWith(COOKIE_NAME + '=')
539
- );
540
- cookies.push(`${COOKIE_NAME}=${this._token}`);
541
- this._client.defaults.headers.Cookie = cookies.join('; ');
542
- }
543
- this._autoRefreshJwt = this._token;
544
- if (this._autoRefreshEnabled) {
545
- this._scheduleTokenRefresh();
546
- }
547
- }
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
-
574
- /**
575
- * Logout from the current session.
576
- */
577
- async logout() {
578
- try {
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 });
583
- } catch (error) {
584
- console.error("Failed to logoff:", error.message);
585
- } finally {
586
- // Clean up keepAlive connections
587
- if (ENV.isNode && this._client.defaults.httpsAgent) {
588
- this._client.defaults.httpsAgent.destroy();
589
- }
590
-
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++;
596
- this._token = null;
597
- this._refreshToken = null;
598
- this._tokenTimes = null;
599
-
600
- // Clear the outgoing auth cookie (Node); the browser's HttpOnly auth cookie is cleared
601
- // server-side on logoff.
602
- if (ENV.isNode) {
603
- this._client.defaults.headers.Cookie = null;
604
- }
605
- }
606
- }
607
-
608
- /**
609
- * Enable or disable background token auto-refresh.
610
- * @param {boolean} enable - true to start, false to stop
611
- * @param {string} [jwtToken] - Optional token used only to calculate the first refresh delay
612
- */
613
- set_auto_refresh_token(enable, jwtToken = null) {
614
- this._stopAutoRefresh();
615
- this._autoRefreshEnabled = enable;
616
- if (enable) {
617
- this._autoRefreshJwt = jwtToken || this._getAccessToken() || null;
618
- this._scheduleTokenRefresh();
619
- }
620
- }
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
-
652
- /** @private */
653
- _stopAutoRefresh() {
654
- this._autoRefreshEnabled = false;
655
- this._refreshFailures = 0;
656
- if (this._refreshTimer) {
657
- clearTimeout(this._refreshTimer);
658
- this._refreshTimer = null;
659
- }
660
- }
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
336
  }
683
337
 
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
-
745
- /** @private */
746
- _scheduleTokenRefresh() {
747
- if (!this._autoRefreshEnabled) return;
748
-
749
- // Replace any pending timer so re-scheduling (e.g. from _handleTokenUpdate) never stacks timers
750
- if (this._refreshTimer) {
751
- clearTimeout(this._refreshTimer);
752
- this._refreshTimer = null;
753
- }
754
-
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;
760
- }
761
-
762
- this._refreshTimer = setTimeout(async () => {
763
- this._refreshTimer = null;
764
- if (!this._autoRefreshEnabled) return;
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
- }
781
- }
782
- this._scheduleTokenRefresh(); // keep the loop armed, failure or not
783
- }, delaySec * 1000);
784
-
785
- // Don't block Node.js process exit
786
- if (this._refreshTimer.unref) {
787
- this._refreshTimer.unref();
788
- }
338
+ /** Return Engine's public Dex/OIDC configuration. */
339
+ async get_auth_config() {
340
+ return (await this._request("get", "/appmesh/auth/config")).data;
789
341
  }
790
342
 
791
- /**
792
- * Renew the current JWT token.
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.
801
- */
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;
815
- const headers = {};
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;
832
- }
833
- // Both modes rotate the refresh token on renew — store the new one for the next cycle.
834
- this._captureRefreshToken(response);
835
- }
836
-
837
- /**
838
- * Get the decoded OTP provisioning URI for the current user.
839
- * @returns {Promise<string>} Decoded `otpauth://...` URI, not just the raw secret field
840
- */
841
- async get_totp_uri() {
842
- const response = await this._request("post", "/appmesh/totp/secret");
843
- return base64Utils.decode(response.data["mfa_uri"]);
844
- }
845
-
846
- /**
847
- * Setup 2FA with a verification code and update the current session token cookie.
848
- * @param {string} totpCode - TOTP verification code
849
- */
850
- async enable_totp(totpCode) {
851
- const headers = { "X-Totp-Code": totpCode };
852
- await this._request("post", "/appmesh/totp/setup", null, { headers });
853
- }
854
-
855
- /**
856
- * Validate a TOTP login challenge and update the current session token cookie.
857
- * @param {string} username - Username
858
- * @param {string} totpChallenge - Server challenge
859
- * @param {string} totpCode - TOTP code
860
- * @param {string|number} [tokenExpire] - Token expiry in seconds or ISO8601 duration (e.g. "P1DT12H", 604800)
861
- */
862
- async validate_totp(username, totpChallenge, totpCode, tokenExpire = CONSTANTS.DEFAULT_TOKEN_EXPIRE_SECONDS) {
863
- const body = {
864
- "user_name": username,
865
- "totp_code": totpCode,
866
- "totp_challenge": totpChallenge,
867
- "expire_seconds": parseDuration(tokenExpire)
868
- };
869
- // Set cookie header for browser
870
- const headers = { "X-Set-Cookie": "true" };
871
- if (this._wantRefreshToken()) headers["X-Refresh-Token-Request"] = "true";
872
-
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;
878
- }
879
-
880
- /**
881
- * Disable TOTP for user
882
- * @param {string} [user='self'] - Username
883
- * @returns {Promise<boolean>} true on success; failures throw AppMeshError
884
- */
885
- async disable_totp(user = "self") {
886
- await this._request("post", `/appmesh/totp/${user}/disable`);
887
- return true;
343
+ /** Return the verified Dex principal represented by the current bearer. */
344
+ async get_current_principal() {
345
+ return (await this._request("get", "/appmesh/principal/self")).data;
888
346
  }
889
347
 
890
348
  /**
@@ -1359,88 +817,26 @@ class AppMeshClient {
1359
817
  }
1360
818
 
1361
819
 
1362
- /**
1363
- * Change user password
1364
- * @param {string} oldPassword - Old password
1365
- * @param {string} newPassword - New password
1366
- * @param {string} [username="self"] - Username
1367
- * @returns {Promise<boolean>} true on success; failures throw AppMeshError
1368
- */
1369
- async update_password(oldPassword, newPassword, username = "self") {
1370
- const body = {
1371
- "old_password": base64Utils.encode(oldPassword),
1372
- "new_password": base64Utils.encode(newPassword)
1373
- };
1374
- await this._request("post", `/appmesh/user/${username}/passwd`, body);
1375
- return true;
1376
- }
1377
-
1378
- /**
1379
- * Add new user
1380
- * @param {string} username - Username
1381
- * @param {Object} userData - User definition
1382
- * @returns {Promise<boolean>} true on success; failures throw AppMeshError
1383
- */
1384
- async add_user(username, userData) {
1385
- await this._request("put", `/appmesh/user/${username}`, userData);
1386
- return true;
1387
- }
1388
-
1389
- /**
1390
- * Delete user
1391
- * @param {string} username - Username
1392
- * @returns {Promise<boolean>} true on success; failures throw AppMeshError
1393
- */
1394
- async delete_user(username) {
1395
- await this._request("delete", `/appmesh/user/${username}`);
1396
- return true;
820
+ /** List Engine authorization overlays keyed by immutable Dex principal ID. */
821
+ async list_principals() {
822
+ return (await this._request("get", "/appmesh/principals")).data;
1397
823
  }
1398
824
 
1399
- /**
1400
- * Lock user account
1401
- * @param {string} username - Username
1402
- * @returns {Promise<boolean>} true on success; failures throw AppMeshError
1403
- */
1404
- async lock_user(username) {
1405
- await this._request("post", `/appmesh/user/${username}/lock`);
825
+ /** Update an Engine authorization overlay; never mutate IdP account data. */
826
+ async update_principal(principalId, policy) {
827
+ await this._request("post", `/appmesh/principal/${encodeURIComponent(principalId)}`, policy);
1406
828
  return true;
1407
829
  }
1408
830
 
1409
- /**
1410
- * Unlock user account
1411
- * @param {string} username - Username
1412
- * @returns {Promise<boolean>} true on success; failures throw AppMeshError
1413
- */
1414
- async unlock_user(username) {
1415
- await this._request("post", `/appmesh/user/${username}/unlock`);
831
+ /** Delete only an Engine authorization overlay. */
832
+ async delete_principal(principalId) {
833
+ await this._request("delete", `/appmesh/principal/${encodeURIComponent(principalId)}`);
1416
834
  return true;
1417
835
  }
1418
836
 
1419
- /**
1420
- * Get user list
1421
- * @returns {Object[]} User array
1422
- */
1423
- async list_users() {
1424
- const response = await this._request("get", "/appmesh/users");
1425
- return response.data;
1426
- }
1427
-
1428
- /**
1429
- * Get current user info
1430
- * @returns {Object} User properties
1431
- */
837
+ /** Safe compatibility alias returning a Principal, not an IdP user. */
1432
838
  async get_current_user() {
1433
- const response = await this._request("get", "/appmesh/user/self");
1434
- return response.data;
1435
- }
1436
-
1437
- /**
1438
- * Get all user groups
1439
- * @returns {Object[]} Group array
1440
- */
1441
- async list_groups() {
1442
- const response = await this._request("get", "/appmesh/user/groups");
1443
- return response.data;
839
+ return this.get_current_principal();
1444
840
  }
1445
841
 
1446
842
  /**
@@ -1452,15 +848,17 @@ class AppMeshClient {
1452
848
  return response.data;
1453
849
  }
1454
850
 
1455
- /**
1456
- * Get user permissions
1457
- * @returns {Object[]} Permission array
1458
- */
1459
- async get_user_permissions() {
1460
- const response = await this._request("get", "/appmesh/user/permissions");
851
+ /** Get effective permissions for the current verified principal. */
852
+ async get_principal_permissions() {
853
+ const response = await this._request("get", "/appmesh/principal/self/permissions");
1461
854
  return response.data;
1462
855
  }
1463
856
 
857
+ /** Safe compatibility alias for get_principal_permissions(). */
858
+ async get_user_permissions() {
859
+ return this.get_principal_permissions();
860
+ }
861
+
1464
862
  /**
1465
863
  * Get all roles and permissions
1466
864
  * @returns {Object[]} Role array
@@ -1544,6 +942,11 @@ class AppMeshClient {
1544
942
  headers[CONSTANTS.HTTP_USER_AGENT_HEADER_NAME] = CONSTANTS.HTTP_USER_AGENT;
1545
943
  }
1546
944
 
945
+ const token = this._getAccessToken();
946
+ if (token) {
947
+ headers[CONSTANTS.HTTP_HEADER_KEY_AUTH] = `Bearer ${token}`;
948
+ }
949
+
1547
950
  // Add forwarding host if specified
1548
951
  if (this.forwardingHost) {
1549
952
  if (this.forwardingHost.includes(":")) {
@@ -1588,7 +991,7 @@ class AppMeshClient {
1588
991
  const requestConfig = {
1589
992
  method,
1590
993
  url: path,
1591
- withCredentials: true, // for browser send cookie
994
+ withCredentials: false,
1592
995
  ...config,
1593
996
  headers: { ...headers },
1594
997
  params: { ...params }
@@ -1599,39 +1002,13 @@ class AppMeshClient {
1599
1002
  }
1600
1003
 
1601
1004
  const response = await this._client(requestConfig);
1602
- if (response.status !== 200) {
1005
+ if (response.status < 200 || response.status >= 300) {
1603
1006
  const errMsg = this._extractErrorMessage(response.data);
1604
- if (response.status === CONSTANTS.HTTP_STATUS_PRECONDITION_REQUIRED) {
1605
- throw new TotpRequiredError(errMsg, response.data);
1606
- }
1607
1007
  throw new AppMeshError(errMsg, response.status, response.data);
1608
1008
  }
1609
1009
 
1610
- // axios response header use lower case
1611
- if (ENV.isNode && response.headers['Set-Cookie'.toLowerCase()]) {
1612
- // Handle array of cookies or single cookie
1613
- const cookies = Array.isArray(response.headers['Set-Cookie'.toLowerCase()]) ?
1614
- response.headers['Set-Cookie'.toLowerCase()] :
1615
- [response.headers['Set-Cookie'.toLowerCase()]];
1616
-
1617
- // Join all cookies with semicolon separator
1618
- this._client.defaults.headers.Cookie = cookies.join('; ');
1619
-
1620
- // Keep the in-memory token store in sync with the server-issued cookie
1621
- // (login, renew, authenticate, TOTP setup/validate)
1622
- const authCookie = cookies
1623
- .map(c => c.split(';')[0].trim())
1624
- .find(c => c.startsWith('appmesh_auth_token='));
1625
- if (authCookie) {
1626
- this._handleTokenUpdate(authCookie.substring('appmesh_auth_token='.length));
1627
- }
1628
- }
1629
-
1630
1010
  return response;
1631
1011
  } catch (error) {
1632
- if (error instanceof AppMeshError && error.statusCode === CONSTANTS.HTTP_STATUS_PRECONDITION_REQUIRED) {
1633
- throw error;
1634
- }
1635
1012
  throw this.onError(error);
1636
1013
  }
1637
1014
  }
@@ -1766,5 +1143,5 @@ class AppRun {
1766
1143
  }
1767
1144
 
1768
1145
  // Export the main classes
1769
- export { AppMeshClient, AppOutput, AppRun, AppMeshError, TotpRequiredError, AppRemovedError, TransportDisconnectedError, DEFAULT_CA_FILE };
1146
+ export { AppMeshClient, AppOutput, AppRun, AppMeshError, AppRemovedError, TransportDisconnectedError, DEFAULT_CA_FILE };
1770
1147
  export default AppMeshClient;