appmesh 1.3.3 → 3.1.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.
@@ -6,7 +6,7 @@ import fs from 'fs'
6
6
  import os from 'os'
7
7
  import { v1 as uuidv1 } from 'uuid'
8
8
  import msgpack from 'msgpack-lite'
9
- import AppMeshClient, { AppMeshError, TotpRequiredError, AppRemovedError, TransportDisconnectedError, DEFAULT_CA_FILE } from './appmesh.js'
9
+ import AppMeshClient, { AppMeshError, AppRemovedError, TransportDisconnectedError, DEFAULT_CA_FILE } from './appmesh.js'
10
10
 
11
11
  // Constants
12
12
  const TCP_BLOCK_SIZE = 16 * 1024 - 128 // TLS-optimized chunk size
@@ -19,14 +19,7 @@ const HTTP_HEADER_KEY_X_RECV_FILE_SOCKET = 'X-Recv-File-Socket'
19
19
  const HTTP_HEADER_KEY_USER_AGENT = 'User-Agent'
20
20
  const HTTP_HEADER_KEY_X_FILE_PATH = 'X-File-Path'
21
21
  const HTTP_HEADER_KEY_AUTH = 'Authorization'
22
-
23
- // Auth endpoints returning a new access_token in the JSON body (allowlist per
24
- // docs/source/SDKContract.md "Auth Token Synchronization").
25
- // Login/auth/totp_validate: apply token only when the request carried X-Set-Cookie: true
26
- const AUTH_SET_COOKIE_PATHS = new Set(['/appmesh/login', '/appmesh/auth', '/appmesh/totp/validate'])
27
- // Renew/setup: always apply (client already has an active session)
28
- const AUTH_RENEW_PATHS = new Set(['/appmesh/token/renew', '/appmesh/totp/setup'])
29
- const LOGOFF_PATH = '/appmesh/self/logoff'
22
+ const HTTP_HEADER_KEY_X_TARGET_HOST = 'X-Target-Host'
30
23
 
31
24
  /**
32
25
  * TCP transport for secure TLS connections and framed App Mesh messages.
@@ -342,7 +335,7 @@ class AppMeshClientTCP extends AppMeshClient {
342
335
  super('https://127.0.0.1:6060', sslConfig)
343
336
  this.tcpTransport = new TCPTransport(tcpAddress, sslConfig)
344
337
 
345
- // Store JWT token explicitly since cookies don't work with TCP
338
+ // Store the caller-owned bearer explicitly for TCP requests.
346
339
  this._token = ''
347
340
  }
348
341
 
@@ -371,59 +364,17 @@ class AppMeshClientTCP extends AppMeshClient {
371
364
  }
372
365
 
373
366
  /**
374
- * Handle token updates for transports that cannot rely on cookies.
375
- *
376
- * This method is called after login, renew_token, and authenticate operations.
377
- * Since TCP doesn't support cookies, we store the token explicitly.
367
+ * Handle caller-owned bearer updates for TCP.
378
368
  *
379
369
  * @param {string} token - New JWT token
380
370
  * @private
381
371
  * @override
382
372
  */
383
373
  _handleTokenUpdate (token) {
384
- // Base implementation stores the token and handles refresh scheduling
385
374
  super._handleTokenUpdate(token)
386
375
  this._token = token || ''
387
376
  }
388
377
 
389
- /**
390
- * Extract and apply a token from auth endpoint responses. TCP has no Set-Cookie,
391
- * so the token comes from the JSON body — only on HTTP 200 and only for the
392
- * SDKContract.md auth endpoint allowlist.
393
- * @param {Object} response - Normalized response object from _request
394
- * @param {string} path - Request URI path
395
- * @param {Object} requestHeaders - Headers sent with the request
396
- * @private
397
- */
398
- _syncTransportToken (response, path, requestHeaders) {
399
- if (response.status !== 200) return
400
-
401
- if (path === LOGOFF_PATH) {
402
- this._handleTokenUpdate(null)
403
- return
404
- }
405
-
406
- // Login/auth/totp_validate: apply only when client requested cookie mode
407
- if (AUTH_SET_COOKIE_PATHS.has(path)) {
408
- if (!requestHeaders || requestHeaders['X-Set-Cookie'] !== 'true') return
409
- } else if (!AUTH_RENEW_PATHS.has(path)) {
410
- return
411
- }
412
-
413
- let data = response.data
414
- // Parse if it's a Buffer containing JSON
415
- if (Buffer.isBuffer(data)) {
416
- try {
417
- data = JSON.parse(data.toString(ENCODING_UTF8))
418
- } catch (e) {
419
- return // Not JSON, ignore
420
- }
421
- }
422
- if (data && data.access_token) {
423
- this._handleTokenUpdate(data.access_token)
424
- }
425
- }
426
-
427
378
  /**
428
379
  * Send an App Mesh request over TCP transport and normalize the response shape.
429
380
  * @private
@@ -447,7 +398,13 @@ class AppMeshClientTCP extends AppMeshClient {
447
398
  // Add authentication token if available
448
399
  const token = this._getAccessToken()
449
400
  if (token) {
450
- request.headers[HTTP_HEADER_KEY_AUTH] = token
401
+ request.headers[HTTP_HEADER_KEY_AUTH] = `Bearer ${token}`
402
+ }
403
+
404
+ if (this.forwardingHost) {
405
+ request.headers[HTTP_HEADER_KEY_X_TARGET_HOST] = this.forwardingHost.includes(':')
406
+ ? this.forwardingHost
407
+ : `${this.forwardingHost}:${this.tcpTransport.address[1]}`
451
408
  }
452
409
 
453
410
  if (options.headers) {
@@ -507,15 +464,9 @@ class AppMeshClientTCP extends AppMeshClient {
507
464
  config: options
508
465
  }
509
466
 
510
- // Sync token from auth endpoint responses (login, authenticate, renew_token, logoff)
511
- this._syncTransportToken(result, path, request.headers)
512
-
513
- // Mirror the HTTP transport: never swallow a non-200 into a success value
514
- if (result.status !== 200) {
467
+ // Mirror the HTTP transport: never swallow a non-2xx into a success value.
468
+ if (result.status < 200 || result.status >= 300) {
515
469
  const errMsg = this._extractErrorMessage(result.data)
516
- if (result.status === 428) {
517
- throw new TotpRequiredError(errMsg, result.data)
518
- }
519
470
  throw new AppMeshError(errMsg, result.status, result.data)
520
471
  }
521
472
 
@@ -581,11 +532,11 @@ class AppMeshClientTCP extends AppMeshClient {
581
532
  *
582
533
  * @param {string} filePath - Remote file path
583
534
  * @param {string} [localFile=null] - Local destination path; defaults to the basename of filePath
584
- * @param {boolean} [applyAttrs=true] - Apply returned mode and best-effort owner/group metadata
535
+ * @param {boolean} [applyAttrs=false] - Apply returned mode and best-effort owner/group metadata
585
536
  * on non-Windows Node.js hosts
586
537
  * @override
587
538
  */
588
- async download_file (filePath, localFile = null, applyAttrs = true) {
539
+ async download_file (filePath, localFile = null, applyAttrs = false) {
589
540
  if (!localFile) localFile = filePath.split(/[\\/]/).pop()
590
541
  const headers = {
591
542
  [HTTP_HEADER_KEY_X_FILE_PATH]: encodeURIComponent(filePath),
@@ -653,11 +604,11 @@ class AppMeshClientTCP extends AppMeshClient {
653
604
  *
654
605
  * @param {string} localFile - Local file path
655
606
  * @param {string} [filePath=null] - Remote destination path; defaults to the basename of localFile
656
- * @param {boolean} [applyAttrs=true] - Send local mode/uid/gid metadata so the server can
607
+ * @param {boolean} [applyAttrs=false] - Send local mode/uid/gid metadata so the server can
657
608
  * recreate permissions and ownership when supported
658
609
  * @override
659
610
  */
660
- async upload_file (localFile, filePath = null, applyAttrs = true) {
611
+ async upload_file (localFile, filePath = null, applyAttrs = false) {
661
612
  if (!filePath) filePath = localFile.split(/[\\/]/).pop()
662
613
  if (!fs.existsSync(localFile)) {
663
614
  throw new Error(`Local file not found: ${localFile}`)
@@ -709,7 +660,7 @@ class AppMeshClientTCP extends AppMeshClient {
709
660
  * @returns {Promise<Object>} Subscription result with subscription_id, app_name, events.
710
661
  */
711
662
  async subscribe (appName, events, callback) {
712
- this._enableDemuxer()
663
+ await this._enableDemuxer()
713
664
 
714
665
  let path = '/appmesh/subscribe'
715
666
  if (appName && appName !== '*') {
@@ -759,7 +710,7 @@ class AppMeshClientTCP extends AppMeshClient {
759
710
  async add_app (name, appJson, subscribeEvents, callback) {
760
711
  const options = {}
761
712
  if (subscribeEvents && subscribeEvents.length > 0) {
762
- this._enableDemuxer()
713
+ await this._enableDemuxer()
763
714
  options.params = { subscribe_events: subscribeEvents.join(',') }
764
715
  }
765
716
 
@@ -777,8 +728,11 @@ class AppMeshClientTCP extends AppMeshClient {
777
728
  * Enable demuxer for concurrent request-response and event routing.
778
729
  * @private
779
730
  */
780
- _enableDemuxer () {
781
- if (this._demuxer) return
731
+ async _enableDemuxer () {
732
+ if (this._demuxer && this._demuxer._running) return
733
+ if (!this.tcpTransport.connected()) {
734
+ await this.tcpTransport.connect()
735
+ }
782
736
  this._demuxer = new MessageDemuxer(this.tcpTransport)
783
737
  this._demuxer.start()
784
738
  }
package/src/worker.js CHANGED
@@ -29,6 +29,15 @@ class ProcessSupersededError extends AppMeshError {
29
29
  }
30
30
  }
31
31
 
32
+ /** Permanent worker request rejection (HTTP 400); retrying cannot recover. */
33
+ class WorkerRejectedError extends AppMeshError {
34
+ /** @param {string} message @param {any} responseData */
35
+ constructor (message, responseData = null) {
36
+ super(message, 400, responseData, 'WORKER_REJECTED')
37
+ this.name = 'WorkerRejectedError'
38
+ }
39
+ }
40
+
32
41
  /**
33
42
  * True when `value` looks like a {host, port} address object rather than an SSL config.
34
43
  * Mirrors the acceptance logic in appmesh_tcp.js (kept local: appmesh_tcp.js is lazy-imported).
@@ -91,10 +100,8 @@ class AppMeshWorker {
91
100
  * @param {Object} [options.logger=console] - Logger instance
92
101
  * @param {AppMeshClient} [options.client] - Optional pre-built `AppMeshClient` instance to reuse.
93
102
  * When supplied, `baseURL` and `sslConfig` are ignored and the worker shares the caller's
94
- * client (and therefore its token-refresh state). Use this when a single process needs both
95
- * client (outbound calls like `AddApp`) and worker (`fetch_task`/`send_task_result`) roles to
96
- * avoid two independent `/token/renew` loops fighting each other — the daemon blacklists
97
- * the previous token on every renew, so the slower refresher gets 401 "Token has been revoked".
103
+ * client (and therefore its caller-owned bearer). Use this when a single process needs both
104
+ * client (outbound calls like `AddApp`) and worker (`fetch_task`/`send_task_result`) roles.
98
105
  *
99
106
  * @example
100
107
  * import fs from 'fs';
@@ -109,7 +116,7 @@ class AppMeshWorker {
109
116
  * @example
110
117
  * // Share a single client between client-role and worker-role usage:
111
118
  * const client = new AppMeshClient('https://127.0.0.1:6060', sslConfig);
112
- * await client.login('user', 'pass');
119
+ * client.set_bearer_token(process.env.APPMESH_BEARER_TOKEN);
113
120
  * const worker = new AppMeshWorker('https://127.0.0.1:6060', sslConfig, { client });
114
121
  */
115
122
  constructor (
@@ -125,7 +132,6 @@ class AppMeshWorker {
125
132
  this._client = options.client
126
133
  } else {
127
134
  this._client = new AppMeshClient(baseURL, sslConfig)
128
- this._client.set_auto_refresh_token(false) // Task endpoints use APP_MESH_PROCESS_KEY; no JWT refresh needed.
129
135
  }
130
136
  this._logger = options.logger || console
131
137
  this._stopped = false
@@ -167,15 +173,17 @@ class AppMeshWorker {
167
173
  * Fetch task data in the currently running App Mesh application process.
168
174
  *
169
175
  * Used by App Mesh application process to obtain the payload from App Mesh service
170
- * that a client pushed to it. This method will retry indefinitely until successful
171
- * or cancelled via {@link AppMeshWorker#stop}, in which case `null` is resolved.
176
+ * that a client pushed to it. This method retries transient failures indefinitely
177
+ * until successful or cancelled via {@link AppMeshWorker#stop}; HTTP 400/412 stop
178
+ * the loop. Cancellation resolves `null`.
172
179
  *
173
180
  * @async
174
181
  * @returns {Promise<string|Buffer|null>} The raw payload bytes/body provided by the
175
182
  * invoking client, or `null` when the fetch loop was cancelled via stop()
176
183
  * @throws {ProcessSupersededError} The daemon reported HTTP 412 — this process key was
177
- * superseded by a newer process instance; the caller should stop serving (an app
178
- * entry point typically catches this and exits).
184
+ * superseded by a newer process instance.
185
+ * @throws {WorkerRejectedError} The daemon reported a permanent HTTP 400; an app entry
186
+ * point should stop serving and exit.
179
187
  */
180
188
  async fetch_task () {
181
189
  const { processKey, appName } = this._getRuntimeEnv()
@@ -187,7 +195,7 @@ class AppMeshWorker {
187
195
  const attemptStart = Date.now()
188
196
  try {
189
197
  const response = await this._client.request('get', path, null, {
190
- params: { process_key: processKey }
198
+ headers: { 'X-AppMesh-Process-Key': processKey }
191
199
  })
192
200
 
193
201
  if (response.status === 200) {
@@ -202,6 +210,12 @@ class AppMeshWorker {
202
210
  response.data
203
211
  )
204
212
  }
213
+ if (response.status === 400) {
214
+ throw new WorkerRejectedError(
215
+ 'fetch_task permanently rejected with status 400',
216
+ response.data
217
+ )
218
+ }
205
219
 
206
220
  this._logger.warn(
207
221
  `fetch_task failed with status ${response.status}: ${response.data}, retrying...`
@@ -218,6 +232,12 @@ class AppMeshWorker {
218
232
  error.responseData
219
233
  )
220
234
  }
235
+ if (error.statusCode === 400) {
236
+ this._logger.error(`fetch_task permanently rejected: ${error.message}`)
237
+ throw error instanceof WorkerRejectedError
238
+ ? error
239
+ : new WorkerRejectedError(error.message, error.responseData)
240
+ }
221
241
  this._logger.warn(`fetch_task error: ${error.message}, retrying...`)
222
242
  }
223
243
 
@@ -247,7 +267,7 @@ class AppMeshWorker {
247
267
  let response
248
268
  try {
249
269
  response = await this._client.request('put', path, result, {
250
- params: { process_key: processKey }
270
+ headers: { 'X-AppMesh-Process-Key': processKey }
251
271
  })
252
272
  } catch (error) {
253
273
  // request() throws AppMeshError on non-200
@@ -309,7 +329,7 @@ class AppMeshWorkerTCP extends AppMeshWorker {
309
329
  * @param {Array<string, number>|{host: string, port: number}} [tcpAddress=['127.0.0.1', 6059]] - TCP server address
310
330
  * @param {Object} [options={}] - Additional options
311
331
  * @param {Object} [options.logger=console] - Logger instance
312
- * @param {AppMeshClientTCP} [options.client] - Pre-built client to reuse (shares token-refresh state); see AppMeshWorker for why. When set, sslConfig/tcpAddress are ignored.
332
+ * @param {AppMeshClientTCP} [options.client] - Pre-built client to reuse (shares its caller-owned bearer); when set, sslConfig/tcpAddress are ignored.
313
333
  *
314
334
  * @example
315
335
  * import fs from 'fs';
@@ -324,7 +344,7 @@ class AppMeshWorkerTCP extends AppMeshWorker {
324
344
  * // Share a single TCP client between client-role and worker-role usage:
325
345
  * import { AppMeshClientTCP } from 'appmesh/tcp';
326
346
  * const client = new AppMeshClientTCP(sslConfig, ['127.0.0.1', 6059]);
327
- * await client.login('user', 'pass');
347
+ * client.set_bearer_token(process.env.APPMESH_BEARER_TOKEN);
328
348
  * const worker = new AppMeshWorkerTCP(sslConfig, ['127.0.0.1', 6059], { client });
329
349
  */
330
350
  constructor (
@@ -353,7 +373,6 @@ class AppMeshWorkerTCP extends AppMeshWorker {
353
373
  if (!this._client) {
354
374
  const { AppMeshClientTCP } = await import('./appmesh_tcp.js')
355
375
  this._client = new AppMeshClientTCP(this._sslConfig, this._tcpAddress)
356
- this._client.set_auto_refresh_token(false) // Task endpoints use APP_MESH_PROCESS_KEY; no JWT refresh needed.
357
376
  }
358
377
  return this._client
359
378
  }
@@ -420,6 +439,7 @@ export {
420
439
  AppMeshWorker,
421
440
  AppMeshWorkerTCP,
422
441
  ProcessSupersededError,
442
+ WorkerRejectedError,
423
443
  withServer
424
444
  }
425
445
  export default AppMeshWorker
@@ -1,367 +0,0 @@
1
- // appmesh_oauth.js - App Mesh client with Keycloak OAuth2 authentication support.
2
- // Mirrors the Python SDK's AppMeshClientOAuth (client_http_oauth.py): tokens are obtained
3
- // directly from Keycloak and the access token is used as the Bearer token for daemon calls.
4
- import axios from "axios";
5
- import AppMeshClient, { AppMeshError } from "./appmesh.js";
6
-
7
- // OAuth2 scopes requesting identity claims so userinfo returns preferred_username/email
8
- const DEFAULT_OAUTH_SCOPE = "openid profile email";
9
-
10
- /**
11
- * AppMeshClient with Keycloak as the identity provider.
12
- *
13
- * Authentication (login, token renewal, logout, userinfo) is performed directly against
14
- * Keycloak's OpenID Connect endpoints; the Keycloak access token is then used as this
15
- * client's token for App Mesh daemon API calls (RBAC is enforced by the daemon).
16
- */
17
- class AppMeshClientOAuth extends AppMeshClient {
18
- /**
19
- * Initialize an App Mesh client with Keycloak support.
20
- * @param {Object} oauth2 - Keycloak configuration for oauth2 authentication:
21
- * - authServerUrl: Keycloak server URL (e.g. "https://keycloak.example.com/auth")
22
- * - realm: Keycloak realm
23
- * - clientId: Keycloak client ID
24
- * - clientSecret: Keycloak client secret (optional, confidential clients only)
25
- * @param {string} [baseURL] - App Mesh daemon URL (see AppMeshClient)
26
- * @param {Object|false|null} [sslConfig] - SSL tri-state for the daemon connection (see AppMeshClient)
27
- */
28
- constructor(oauth2, baseURL = undefined, sslConfig = null) {
29
- super(baseURL, sslConfig);
30
-
31
- if (!oauth2 || !oauth2.authServerUrl || !oauth2.realm || !oauth2.clientId) {
32
- throw new AppMeshError(
33
- "oauth2 config requires authServerUrl, realm and clientId",
34
- 400,
35
- null,
36
- "INVALID_OAUTH_CONFIG",
37
- );
38
- }
39
-
40
- /** @type {Object} Keycloak configuration @private */
41
- this._oauth2 = {
42
- authServerUrl: String(oauth2.authServerUrl).replace(/\/+$/, ""),
43
- realm: oauth2.realm,
44
- clientId: oauth2.clientId,
45
- clientSecret: oauth2.clientSecret || null,
46
- };
47
-
48
- /** @type {Object} Complete Keycloak token response (access_token, refresh_token, ...) @private */
49
- this._oauthToken = {};
50
-
51
- // Dedicated axios instance for Keycloak endpoints (the inherited this._client
52
- // targets the App Mesh daemon and injects daemon-specific headers/cookies)
53
- /** @private */
54
- this._oauthClient = axios.create({
55
- timeout: 60000,
56
- validateStatus: () => true,
57
- });
58
- }
59
-
60
- /**
61
- * Build a Keycloak OpenID Connect endpoint URL for the configured realm.
62
- * @param {string} name - Endpoint name (e.g. "token", "auth/device", "logout", "userinfo")
63
- * @returns {string} Full endpoint URL
64
- * @private
65
- */
66
- _oauthEndpoint(name) {
67
- return `${this._oauth2.authServerUrl}/realms/${encodeURIComponent(this._oauth2.realm)}/protocol/openid-connect/${name}`;
68
- }
69
-
70
- /**
71
- * POST x-www-form-urlencoded params to a Keycloak endpoint, adding client credentials.
72
- * @param {string} endpoint - Endpoint name for _oauthEndpoint()
73
- * @param {Object} params - Form fields (null/undefined values are skipped)
74
- * @returns {Promise<any>} Raw axios response (any status)
75
- * @private
76
- */
77
- async _oauthPost(endpoint, params) {
78
- const form = new URLSearchParams();
79
- form.append("client_id", this._oauth2.clientId);
80
- if (this._oauth2.clientSecret)
81
- form.append("client_secret", this._oauth2.clientSecret);
82
- for (const [key, value] of Object.entries(params)) {
83
- // Values are appended as-is (strings): TOTP codes keep leading zeros
84
- if (value !== null && value !== undefined)
85
- form.append(key, String(value));
86
- }
87
- return this._oauthClient.post(this._oauthEndpoint(endpoint), form);
88
- }
89
-
90
- /**
91
- * Extract the OAuth2 "error" code from a Keycloak error response body.
92
- * @param {any} data - Response body
93
- * @returns {string} OAuth2 error code or ""
94
- * @private
95
- */
96
- static _oauthErrorCode(data) {
97
- if (typeof data === "string") {
98
- try {
99
- data = JSON.parse(data);
100
- } catch (_) {
101
- return "";
102
- }
103
- }
104
- return data && typeof data === "object" && typeof data.error === "string"
105
- ? data.error
106
- : "";
107
- }
108
-
109
- /**
110
- * Store a full Keycloak token response and propagate the access token to the base client.
111
- * @param {Object} tokenResponse - Complete Keycloak token endpoint response
112
- * @private
113
- */
114
- _storeOauthToken(tokenResponse) {
115
- this._oauthToken = tokenResponse || {};
116
- this._handleTokenUpdate(this._oauthToken.access_token || null);
117
- }
118
-
119
- /**
120
- * Login with username and password using the OAuth2 Direct Access Grant (Resource
121
- * Owner Password Credentials) against Keycloak.
122
- * @param {string} username - The name of the user
123
- * @param {string} password - The password of the user
124
- * @param {string} [totpCode] - TOTP code if enabled for the user. Passed verbatim as a
125
- * string: leading zeros matter (e.g. "012345")
126
- * @param {string|number} [tokenExpire] - Ignored by the Keycloak login flow
127
- * @param {string} [audience] - Ignored by the Keycloak login flow
128
- * @returns {Promise<void>} Resolves when login succeeds
129
- * @throws {AppMeshError} If authentication fails
130
- */
131
- async login(
132
- username,
133
- password,
134
- totpCode = null,
135
- tokenExpire = null,
136
- audience = null,
137
- ) {
138
- if (tokenExpire || audience) {
139
- console.warn(
140
- "tokenExpire and audience are ignored by the Keycloak login flow",
141
- );
142
- }
143
- if (!username || !password) {
144
- throw new AppMeshError(
145
- "Username and password are required",
146
- 400,
147
- null,
148
- "INVALID_CREDENTIALS",
149
- );
150
- }
151
-
152
- const response = await this._oauthPost("token", {
153
- grant_type: "password",
154
- username,
155
- password,
156
- scope: DEFAULT_OAUTH_SCOPE,
157
- // Pass TOTP as-is (string): a numeric conversion would strip leading zeros
158
- totp: totpCode ? String(totpCode) : null,
159
- });
160
- if (response.status !== 200) {
161
- throw new AppMeshError(
162
- `Keycloak login failed: ${this._extractErrorMessage(response.data)}`,
163
- response.status,
164
- response.data,
165
- "OAUTH_LOGIN_FAILED",
166
- );
167
- }
168
- this._storeOauthToken(response.data);
169
- }
170
-
171
- /**
172
- * Login via the OAuth 2.0 Device Authorization Grant (RFC 8628).
173
- *
174
- * For browserless/input-constrained environments: the user opens
175
- * `verification_uri_complete` (or `verification_uri` + `user_code`) on another device,
176
- * and this call polls the token endpoint until approval, denial, or expiry.
177
- *
178
- * Requires "OAuth 2.0 Device Authorization Grant" enabled on the Keycloak client.
179
- *
180
- * @param {Function} [onPrompt] - Callback receiving the device authorization response
181
- * (keys per RFC 8628 §3.2: `user_code`, `verification_uri`, `verification_uri_complete`,
182
- * `expires_in`, `interval`) to present the instructions to the user. Defaults to
183
- * printing them to the console.
184
- * @param {string} [scope] - OAuth2 scopes to request
185
- * @returns {Promise<void>} Resolves once the user approved the request
186
- * @throws {AppMeshError} When the user denies the request, the device code expires,
187
- * or the token request fails for any other reason
188
- */
189
- async login_device_flow(onPrompt = null, scope = DEFAULT_OAUTH_SCOPE) {
190
- const deviceResponse = await this._oauthPost("auth/device", { scope });
191
- if (deviceResponse.status !== 200) {
192
- throw new AppMeshError(
193
- `Device authorization request failed: ${this._extractErrorMessage(deviceResponse.data)}`,
194
- deviceResponse.status,
195
- deviceResponse.data,
196
- "OAUTH_DEVICE_FLOW_FAILED",
197
- );
198
- }
199
- const device = deviceResponse.data;
200
-
201
- if (onPrompt) {
202
- onPrompt(device);
203
- } else {
204
- const uri = device.verification_uri_complete || device.verification_uri;
205
- console.log(
206
- `To sign in, open ${uri} and enter code: ${device.user_code}`,
207
- );
208
- }
209
-
210
- // RFC 8628 §3.5: poll no faster than "interval", stop once the device code expires
211
- let interval = parseInt(device.interval, 10) || 5;
212
- const deadline =
213
- Date.now() + (parseInt(device.expires_in, 10) || 600) * 1000;
214
-
215
- for (;;) {
216
- const remaining = (deadline - Date.now()) / 1000;
217
- if (remaining <= 0) {
218
- throw new AppMeshError(
219
- "Device authorization expired before the user approved the request",
220
- null,
221
- null,
222
- "OAUTH_DEVICE_FLOW_EXPIRED",
223
- );
224
- }
225
- await this._sleep(Math.min(interval, remaining));
226
-
227
- const response = await this._oauthPost("token", {
228
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
229
- device_code: device.device_code,
230
- });
231
- if (response.status === 200) {
232
- this._storeOauthToken(response.data);
233
- return;
234
- }
235
- const error = AppMeshClientOAuth._oauthErrorCode(response.data);
236
- if (error === "authorization_pending") {
237
- continue;
238
- }
239
- if (error === "slow_down") {
240
- interval += 5; // RFC 8628 §3.5: back off by 5 seconds
241
- continue;
242
- }
243
- throw new AppMeshError(
244
- `Device authorization failed: ${error || this._extractErrorMessage(response.data)}`,
245
- response.status,
246
- response.data,
247
- "OAUTH_DEVICE_FLOW_FAILED",
248
- );
249
- }
250
- }
251
-
252
- /**
253
- * Sleep helper used by the device-flow polling loop.
254
- * @param {number} seconds - Duration in seconds
255
- * @returns {Promise<void>}
256
- * @protected
257
- */
258
- _sleep(seconds) {
259
- return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
260
- }
261
-
262
- /**
263
- * Renew the current Keycloak token using the refresh token.
264
- * Keycloak rotates the refresh token: the complete new token response is stored.
265
- * @param {string|number} [tokenExpire] - Ignored by the Keycloak refresh flow
266
- * @returns {Promise<void>}
267
- * @throws {AppMeshError} If no refresh token is available or the renewal fails
268
- */
269
- async renew_token(tokenExpire = null) {
270
- // eslint-disable-line no-unused-vars
271
- const refreshToken = this._oauthToken && this._oauthToken.refresh_token;
272
- if (!refreshToken) {
273
- throw new AppMeshError(
274
- "No Keycloak refresh token available to renew",
275
- null,
276
- null,
277
- "OAUTH_NO_REFRESH_TOKEN",
278
- );
279
- }
280
-
281
- const response = await this._oauthPost("token", {
282
- grant_type: "refresh_token",
283
- refresh_token: refreshToken,
284
- });
285
- if (response.status !== 200) {
286
- throw new AppMeshError(
287
- `Keycloak token renewal failed: ${this._extractErrorMessage(response.data)}`,
288
- response.status,
289
- response.data,
290
- "OAUTH_RENEW_FAILED",
291
- );
292
- }
293
- // Keycloak rotates refresh tokens: replace the whole stored token response
294
- this._storeOauthToken(response.data);
295
- }
296
-
297
- /**
298
- * Log out of the current session from Keycloak and the App Mesh daemon.
299
- * The daemon logoff (super.logout) runs BEFORE the local token is cleared so it can
300
- * still authenticate; the Keycloak session is revoked via the refresh token.
301
- * @returns {Promise<boolean>} true when the Keycloak session was revoked
302
- */
303
- async logout() {
304
- let result = false;
305
- const refreshToken = this._oauthToken && this._oauthToken.refresh_token;
306
- if (refreshToken) {
307
- try {
308
- const response = await this._oauthPost("logout", {
309
- refresh_token: refreshToken,
310
- });
311
- if (response.status >= 200 && response.status < 300) {
312
- result = true;
313
- } else {
314
- console.warn(
315
- `Failed to logout from Keycloak: ${this._extractErrorMessage(response.data)}`,
316
- );
317
- }
318
- } catch (error) {
319
- console.warn(`Failed to logout from Keycloak: ${error.message}`);
320
- }
321
- }
322
-
323
- // Daemon-side logoff before clearing the local token (it still needs the token)
324
- await super.logout();
325
- this._oauthToken = {};
326
-
327
- return result;
328
- }
329
-
330
- /**
331
- * Get Keycloak OIDC userinfo for the current access token, directly from Keycloak.
332
- * Unlike get_current_user() (inherited), which asks the App Mesh daemon
333
- * /appmesh/user/self, this queries the Keycloak userinfo endpoint without the daemon.
334
- * @returns {Promise<Object>} Keycloak userinfo (OIDC claims such as `sub`,
335
- * `preferred_username`, `email`)
336
- * @throws {AppMeshError} If no access token is available or the request fails
337
- */
338
- async get_oauth_userinfo() {
339
- const accessToken = this._oauthToken && this._oauthToken.access_token;
340
- if (!accessToken) {
341
- throw new AppMeshError(
342
- "No Keycloak access token available",
343
- null,
344
- null,
345
- "OAUTH_NO_ACCESS_TOKEN",
346
- );
347
- }
348
- const response = await this._oauthClient.get(
349
- this._oauthEndpoint("userinfo"),
350
- {
351
- headers: { Authorization: `Bearer ${accessToken}` },
352
- },
353
- );
354
- if (response.status !== 200) {
355
- throw new AppMeshError(
356
- `Keycloak userinfo request failed: ${this._extractErrorMessage(response.data)}`,
357
- response.status,
358
- response.data,
359
- "OAUTH_USERINFO_FAILED",
360
- );
361
- }
362
- return response.data;
363
- }
364
- }
365
-
366
- export { AppMeshClientOAuth };
367
- export default AppMeshClientOAuth;