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/README.md +3 -3
- package/dist/appmesh.esm.js +1 -1
- package/package.json +3 -13
- package/src/appmesh.js +46 -669
- package/src/appmesh_tcp.js +13 -66
- package/src/worker.js +7 -11
- 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
|
|
@@ -20,14 +20,6 @@ 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
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'
|
|
30
|
-
|
|
31
23
|
/**
|
|
32
24
|
* TCP transport for secure TLS connections and framed App Mesh messages.
|
|
33
25
|
*
|
|
@@ -342,7 +334,7 @@ class AppMeshClientTCP extends AppMeshClient {
|
|
|
342
334
|
super('https://127.0.0.1:6060', sslConfig)
|
|
343
335
|
this.tcpTransport = new TCPTransport(tcpAddress, sslConfig)
|
|
344
336
|
|
|
345
|
-
// Store
|
|
337
|
+
// Store the caller-owned bearer explicitly for TCP requests.
|
|
346
338
|
this._token = ''
|
|
347
339
|
}
|
|
348
340
|
|
|
@@ -371,59 +363,17 @@ class AppMeshClientTCP extends AppMeshClient {
|
|
|
371
363
|
}
|
|
372
364
|
|
|
373
365
|
/**
|
|
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.
|
|
366
|
+
* Handle caller-owned bearer updates for TCP.
|
|
378
367
|
*
|
|
379
368
|
* @param {string} token - New JWT token
|
|
380
369
|
* @private
|
|
381
370
|
* @override
|
|
382
371
|
*/
|
|
383
372
|
_handleTokenUpdate (token) {
|
|
384
|
-
// Base implementation stores the token and handles refresh scheduling
|
|
385
373
|
super._handleTokenUpdate(token)
|
|
386
374
|
this._token = token || ''
|
|
387
375
|
}
|
|
388
376
|
|
|
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
377
|
/**
|
|
428
378
|
* Send an App Mesh request over TCP transport and normalize the response shape.
|
|
429
379
|
* @private
|
|
@@ -447,7 +397,7 @@ class AppMeshClientTCP extends AppMeshClient {
|
|
|
447
397
|
// Add authentication token if available
|
|
448
398
|
const token = this._getAccessToken()
|
|
449
399
|
if (token) {
|
|
450
|
-
request.headers[HTTP_HEADER_KEY_AUTH] = token
|
|
400
|
+
request.headers[HTTP_HEADER_KEY_AUTH] = `Bearer ${token}`
|
|
451
401
|
}
|
|
452
402
|
|
|
453
403
|
if (options.headers) {
|
|
@@ -507,15 +457,9 @@ class AppMeshClientTCP extends AppMeshClient {
|
|
|
507
457
|
config: options
|
|
508
458
|
}
|
|
509
459
|
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
// Mirror the HTTP transport: never swallow a non-200 into a success value
|
|
514
|
-
if (result.status !== 200) {
|
|
460
|
+
// Mirror the HTTP transport: never swallow a non-2xx into a success value.
|
|
461
|
+
if (result.status < 200 || result.status >= 300) {
|
|
515
462
|
const errMsg = this._extractErrorMessage(result.data)
|
|
516
|
-
if (result.status === 428) {
|
|
517
|
-
throw new TotpRequiredError(errMsg, result.data)
|
|
518
|
-
}
|
|
519
463
|
throw new AppMeshError(errMsg, result.status, result.data)
|
|
520
464
|
}
|
|
521
465
|
|
|
@@ -709,7 +653,7 @@ class AppMeshClientTCP extends AppMeshClient {
|
|
|
709
653
|
* @returns {Promise<Object>} Subscription result with subscription_id, app_name, events.
|
|
710
654
|
*/
|
|
711
655
|
async subscribe (appName, events, callback) {
|
|
712
|
-
this._enableDemuxer()
|
|
656
|
+
await this._enableDemuxer()
|
|
713
657
|
|
|
714
658
|
let path = '/appmesh/subscribe'
|
|
715
659
|
if (appName && appName !== '*') {
|
|
@@ -759,7 +703,7 @@ class AppMeshClientTCP extends AppMeshClient {
|
|
|
759
703
|
async add_app (name, appJson, subscribeEvents, callback) {
|
|
760
704
|
const options = {}
|
|
761
705
|
if (subscribeEvents && subscribeEvents.length > 0) {
|
|
762
|
-
this._enableDemuxer()
|
|
706
|
+
await this._enableDemuxer()
|
|
763
707
|
options.params = { subscribe_events: subscribeEvents.join(',') }
|
|
764
708
|
}
|
|
765
709
|
|
|
@@ -777,8 +721,11 @@ class AppMeshClientTCP extends AppMeshClient {
|
|
|
777
721
|
* Enable demuxer for concurrent request-response and event routing.
|
|
778
722
|
* @private
|
|
779
723
|
*/
|
|
780
|
-
_enableDemuxer () {
|
|
781
|
-
if (this._demuxer) return
|
|
724
|
+
async _enableDemuxer () {
|
|
725
|
+
if (this._demuxer && this._demuxer._running) return
|
|
726
|
+
if (!this.tcpTransport.connected()) {
|
|
727
|
+
await this.tcpTransport.connect()
|
|
728
|
+
}
|
|
782
729
|
this._demuxer = new MessageDemuxer(this.tcpTransport)
|
|
783
730
|
this._demuxer.start()
|
|
784
731
|
}
|
package/src/worker.js
CHANGED
|
@@ -91,10 +91,8 @@ class AppMeshWorker {
|
|
|
91
91
|
* @param {Object} [options.logger=console] - Logger instance
|
|
92
92
|
* @param {AppMeshClient} [options.client] - Optional pre-built `AppMeshClient` instance to reuse.
|
|
93
93
|
* 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".
|
|
94
|
+
* client (and therefore its caller-owned bearer). Use this when a single process needs both
|
|
95
|
+
* client (outbound calls like `AddApp`) and worker (`fetch_task`/`send_task_result`) roles.
|
|
98
96
|
*
|
|
99
97
|
* @example
|
|
100
98
|
* import fs from 'fs';
|
|
@@ -109,7 +107,7 @@ class AppMeshWorker {
|
|
|
109
107
|
* @example
|
|
110
108
|
* // Share a single client between client-role and worker-role usage:
|
|
111
109
|
* const client = new AppMeshClient('https://127.0.0.1:6060', sslConfig);
|
|
112
|
-
*
|
|
110
|
+
* client.set_bearer_token(process.env.APPMESH_BEARER_TOKEN);
|
|
113
111
|
* const worker = new AppMeshWorker('https://127.0.0.1:6060', sslConfig, { client });
|
|
114
112
|
*/
|
|
115
113
|
constructor (
|
|
@@ -125,7 +123,6 @@ class AppMeshWorker {
|
|
|
125
123
|
this._client = options.client
|
|
126
124
|
} else {
|
|
127
125
|
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
126
|
}
|
|
130
127
|
this._logger = options.logger || console
|
|
131
128
|
this._stopped = false
|
|
@@ -187,7 +184,7 @@ class AppMeshWorker {
|
|
|
187
184
|
const attemptStart = Date.now()
|
|
188
185
|
try {
|
|
189
186
|
const response = await this._client.request('get', path, null, {
|
|
190
|
-
|
|
187
|
+
headers: { 'X-AppMesh-Process-Key': processKey }
|
|
191
188
|
})
|
|
192
189
|
|
|
193
190
|
if (response.status === 200) {
|
|
@@ -247,7 +244,7 @@ class AppMeshWorker {
|
|
|
247
244
|
let response
|
|
248
245
|
try {
|
|
249
246
|
response = await this._client.request('put', path, result, {
|
|
250
|
-
|
|
247
|
+
headers: { 'X-AppMesh-Process-Key': processKey }
|
|
251
248
|
})
|
|
252
249
|
} catch (error) {
|
|
253
250
|
// request() throws AppMeshError on non-200
|
|
@@ -309,7 +306,7 @@ class AppMeshWorkerTCP extends AppMeshWorker {
|
|
|
309
306
|
* @param {Array<string, number>|{host: string, port: number}} [tcpAddress=['127.0.0.1', 6059]] - TCP server address
|
|
310
307
|
* @param {Object} [options={}] - Additional options
|
|
311
308
|
* @param {Object} [options.logger=console] - Logger instance
|
|
312
|
-
* @param {AppMeshClientTCP} [options.client] - Pre-built client to reuse (shares
|
|
309
|
+
* @param {AppMeshClientTCP} [options.client] - Pre-built client to reuse (shares its caller-owned bearer); when set, sslConfig/tcpAddress are ignored.
|
|
313
310
|
*
|
|
314
311
|
* @example
|
|
315
312
|
* import fs from 'fs';
|
|
@@ -324,7 +321,7 @@ class AppMeshWorkerTCP extends AppMeshWorker {
|
|
|
324
321
|
* // Share a single TCP client between client-role and worker-role usage:
|
|
325
322
|
* import { AppMeshClientTCP } from 'appmesh/tcp';
|
|
326
323
|
* const client = new AppMeshClientTCP(sslConfig, ['127.0.0.1', 6059]);
|
|
327
|
-
*
|
|
324
|
+
* client.set_bearer_token(process.env.APPMESH_BEARER_TOKEN);
|
|
328
325
|
* const worker = new AppMeshWorkerTCP(sslConfig, ['127.0.0.1', 6059], { client });
|
|
329
326
|
*/
|
|
330
327
|
constructor (
|
|
@@ -353,7 +350,6 @@ class AppMeshWorkerTCP extends AppMeshWorker {
|
|
|
353
350
|
if (!this._client) {
|
|
354
351
|
const { AppMeshClientTCP } = await import('./appmesh_tcp.js')
|
|
355
352
|
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
353
|
}
|
|
358
354
|
return this._client
|
|
359
355
|
}
|
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;
|