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.
- package/README.md +8 -8
- package/dist/453.appmesh.esm.js +1 -1
- package/dist/603.appmesh.esm.js +1 -1
- package/dist/840.appmesh.esm.js +1 -1
- package/dist/894.appmesh.esm.js +1 -1
- package/dist/994.appmesh.esm.js +1 -1
- package/dist/appmesh.esm.js +1 -1
- package/package.json +3 -13
- package/src/appmesh.js +50 -673
- package/src/appmesh_tcp.js +24 -70
- package/src/worker.js +35 -15
- package/src/appmesh_oauth.js +0 -367
package/src/appmesh_tcp.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
-
//
|
|
511
|
-
|
|
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=
|
|
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 =
|
|
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=
|
|
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 =
|
|
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
|
|
95
|
-
* client (outbound calls like `AddApp`) and worker (`fetch_task`/`send_task_result`) roles
|
|
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
|
-
*
|
|
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
|
|
171
|
-
* or cancelled via {@link AppMeshWorker#stop}
|
|
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
|
|
178
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
package/src/appmesh_oauth.js
DELETED
|
@@ -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;
|