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.
@@ -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;