appmesh 1.3.2 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/appmesh.esm.js +1 -1
- package/package.json +8 -1
- package/src/appmesh.js +290 -38
- package/src/appmesh_oauth.js +367 -0
|
@@ -0,0 +1,367 @@
|
|
|
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;
|