@ti-engine/web-framework 1.19.0 → 1.20.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.
Files changed (52) hide show
  1. package/.env +4 -4
  2. package/CHANGELOG.md +384 -353
  3. package/README.md +73 -73
  4. package/bin/build/post-install.js +18 -18
  5. package/bin/localization/web-server-labels.json +27 -27
  6. package/bin/static/.well-known/appspecific/com.chrome.devtools.json +5 -5
  7. package/bin/static/fragments/components/component-notification-bar.html +21 -21
  8. package/bin/static/fragments/components/component-sidebar.html +33 -33
  9. package/bin/static/fragments/components/component-tooltip.html +10 -10
  10. package/bin/static/fragments/components/component-topbar.html +5 -5
  11. package/bin/static/fragments/frame-administration.html +2 -2
  12. package/bin/static/fragments/frame-application.html +18 -18
  13. package/bin/static/fragments/frame-dashboard.html +2 -2
  14. package/bin/static/fragments/frame-login.html +119 -119
  15. package/bin/static/fragments/frame-not-found.html +2 -2
  16. package/bin/static/fragments/frame-profile.html +2 -2
  17. package/bin/static/index.html +22 -22
  18. package/bin/static/scripts/ti-charts.js +1591 -1591
  19. package/bin/static/scripts/ti-framework.css +3194 -3194
  20. package/bin/static/scripts/ti-framework.js +1427 -1427
  21. package/bin/static/scripts/ti-theme-black-glass.css +216 -216
  22. package/bin/static/scripts/ti-theme-daylight.css +87 -87
  23. package/bin/web-app-manager.js +660 -663
  24. package/bin/web-server.js +936 -937
  25. package/bin/web-server.json +48 -48
  26. package/components/admin-config-handlers.js +95 -92
  27. package/components/auth-manager.js +438 -442
  28. package/components/authorization.js +135 -135
  29. package/components/config-change-notifier.js +98 -98
  30. package/components/config-registry.js +257 -260
  31. package/components/config-service.js +363 -360
  32. package/components/config-store.js +244 -246
  33. package/components/definitions.types.js +28 -26
  34. package/components/session-store.js +113 -110
  35. package/components/user.js +134 -132
  36. package/components/web-config-env.js +85 -85
  37. package/components/web-handlers.js +803 -800
  38. package/package.json +139 -67
  39. package/types/bin/web-app-manager.d.ts +194 -0
  40. package/types/bin/web-server.d.ts +373 -0
  41. package/types/components/admin-config-handlers.d.ts +11 -0
  42. package/types/components/auth-manager.d.ts +125 -0
  43. package/types/components/authorization.d.ts +54 -0
  44. package/types/components/config-change-notifier.d.ts +73 -0
  45. package/types/components/config-registry.d.ts +149 -0
  46. package/types/components/config-service.d.ts +218 -0
  47. package/types/components/config-store.d.ts +128 -0
  48. package/types/components/definitions.types.d.ts +31 -0
  49. package/types/components/session-store.d.ts +56 -0
  50. package/types/components/user.d.ts +83 -0
  51. package/types/components/web-config-env.d.ts +17 -0
  52. package/types/components/web-handlers.d.ts +23 -0
@@ -1,442 +1,438 @@
1
- /*
2
- * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
- * Copyright © 2021-2025 Boris Kostadinov <kostadinov.boris@gmail.com>
4
- * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
- * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
- */
8
-
9
- const tools = require( "@ti-engine/core/tools" );
10
- const logger = require( "@ti-engine/core/logger" );
11
- const exceptions = require( "@ti-engine/core/exceptions" );
12
- const { randomBytes } = require( "node:crypto" );
13
- const openidClient = require( "openid-client" );
14
- const User = require( "#user" );
15
-
16
- /**
17
- * Enum for specifying the authentication method.
18
- *
19
- * @readonly
20
- * @enum {string}
21
- * @typedef {string} TiAuthMethod
22
- */
23
- const authMethodEnum = tools.enum( {
24
- LOCAL: [ "local", "local", "Local authentication with username and password." ],
25
- OPENID_AZURE: [ "openid-azure", "openid-azure", "Authentication to Azure Cloud using OpenID Connect." ],
26
- OPENID_GOOGLE: [ "openid-google", "openid-google", "Authentication to Google Cloud using OpenID Connect." ]
27
- } );
28
-
29
- /**
30
- * Enum for specifying the OpenID Connect client authentication method.
31
- *
32
- * @readonly
33
- * @enum {string}
34
- * @typedef {string} TiTokenEndpointAuthMethod
35
- */
36
- const openIDTokenEndpointAuthMethodEnum = tools.enum( {
37
- BASIC: [ "client_secret_basic", "basic", "Uses 'client_secret_basic' token endpoint authentication method." ],
38
- POST: [ "client_secret_post", "post", "Uses 'client_secret_post' token endpoint authentication method." ],
39
- NONE: [ "none", "none", "Uses 'none' token endpoint authentication method." ]
40
- } );
41
-
42
- /**
43
- * The AuthManager class is used to manage authentication and authorization.
44
- *
45
- * @class AuthManager
46
- * @public
47
- */
48
- class AuthManager {
49
-
50
- #initialized = false;
51
- /** @type {SettingsAuth} */
52
- #authSettings = {
53
- enabledMethods: [],
54
- local: {
55
- username: undefined,
56
- password: undefined
57
- },
58
- oauth2: {}
59
- };
60
- #clientConfigOAuth2Google = {};
61
- #clientConfigOAuth2Azure = {};
62
-
63
- /**
64
- * @constructor
65
- * @param {SettingsAuth} settings
66
- */
67
- constructor( settings ) {
68
- if ( settings ) {
69
- this.#authSettings = settings;
70
- }
71
-
72
- // Set up local authentication configuration:
73
- if ( this.isAuthEnabled( authMethodEnum.LOCAL ) ) {
74
- // TODO: For testing purposes only! Implement real local auth later!
75
- this.#authSettings.local = this.#authSettings.local || {};
76
- this.#authSettings.local.username = "admin";
77
- this.#authSettings.local.password = "admin";
78
- }
79
-
80
- // Set up OAuth2 configuration:
81
- this.#authSettings.oauth2 = this.#authSettings.oauth2 || {};
82
- if ( this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
83
- this.#authSettings.oauth2.google = this.#authSettings.oauth2.google || {};
84
- this.#authSettings.oauth2.google.clientID = process.env.TI_GCLOUD_AUTH_CLIENT_ID || this.#authSettings.oauth2.google.clientID;
85
- this.#authSettings.oauth2.google.clientSecret = process.env.TI_GCLOUD_AUTH_CLIENT_SECRET || this.#authSettings.oauth2.google.clientSecret;
86
- this.#authSettings.oauth2.google.callbackUrl = process.env.TI_GCLOUD_AUTH_CALLBACK_URL || this.#authSettings.oauth2.google.callbackUrl;
87
- this.#authSettings.oauth2.google.discoveryUrl = process.env.TI_GCLOUD_AUTH_DISCOVERY_URL || this.#authSettings.oauth2.google.discoveryUrl;
88
- }
89
- if ( this.isAuthEnabled( authMethodEnum.OPENID_AZURE ) ) {
90
- this.#authSettings.oauth2.azure = this.#authSettings.oauth2.azure || {};
91
- this.#authSettings.oauth2.azure.clientID = process.env.TI_AZURE_AUTH_CLIENT_ID || this.#authSettings.oauth2.azure.clientID;
92
- this.#authSettings.oauth2.azure.clientSecret = process.env.TI_AZURE_AUTH_CLIENT_SECRET || this.#authSettings.oauth2.azure.clientSecret;
93
- this.#authSettings.oauth2.azure.callbackUrl = process.env.TI_AZURE_AUTH_CALLBACK_URL || this.#authSettings.oauth2.azure.callbackUrl;
94
- this.#authSettings.oauth2.azure.discoveryUrl = process.env.TI_AZURE_AUTH_DISCOVERY_URL || this.#authSettings.oauth2.azure.discoveryUrl;
95
- }
96
- }
97
-
98
- /* Public interface */
99
-
100
- /**
101
- * Used to initialize the authentication manager.
102
- *
103
- * @method
104
- * @returns {Promise}
105
- * @public
106
- */
107
- initialize() {
108
- // Drop any OpenID Connect provider that is enabled but not configured (missing a client ID) so the
109
- // instance boots with the remaining methods instead of crashing during discovery — e.g. a container
110
- // started without OAuth credentials falls back to whatever else is enabled rather than failing to start.
111
- this.#dropUnconfiguredOpenIDProviders();
112
-
113
- let promises = [];
114
- if ( this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
115
- promises.push( this.#initializeOpenIDClient( this.#authSettings.oauth2.google ).then( ( configuration ) => {
116
- this.#clientConfigOAuth2Google = configuration;
117
- logger.log( "Enabled OpenID Connect authentication with Google Cloud.", logger.logSeverity.NOTICE );
118
- } ) );
119
- }
120
- if ( this.isAuthEnabled( authMethodEnum.OPENID_AZURE ) ) {
121
- promises.push( this.#initializeOpenIDClient( this.#authSettings.oauth2.azure ).then( ( configuration ) => {
122
- this.#clientConfigOAuth2Azure = configuration;
123
- logger.log( "Enabled OpenID Connect authentication with Azure Cloud.", logger.logSeverity.NOTICE );
124
- } ) );
125
- }
126
-
127
- return Promise.all( promises ).then( () => {
128
- this.#initialized = true;
129
- } );
130
- }
131
-
132
- /**
133
- * Used to check whether the specified authentication method is enabled.
134
- *
135
- * @method
136
- * @param {TiAuthMethod} authMethod
137
- * @returns {boolean}
138
- * @public
139
- */
140
- isAuthEnabled( authMethod ) {
141
- return this.#authSettings.enabledMethods.includes( authMethod );
142
- }
143
-
144
- /**
145
- * Returns the list of currently enabled authentication methods, reflecting any OpenID providers dropped by
146
- * {@link AuthManager#initialize} for being enabled but unconfigured. Callers (e.g. the login-page renderer)
147
- * use this to present only the methods a user can actually complete.
148
- *
149
- * @method
150
- * @returns {TiAuthMethod[]}
151
- * @public
152
- */
153
- getEnabledMethods() {
154
- return [ ...this.#authSettings.enabledMethods ];
155
- }
156
-
157
- /**
158
- * Used to authenticate a user via the specified authentication method.
159
- *
160
- * @method
161
- * @param {TiAuthMethod} authMethod
162
- * @param {Object} authDetails
163
- * @returns {Promise<Object>}
164
- * @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the authentication method is not recognized or enabled.
165
- * @throws {TiException.E_GEN_NOT_INITIALIZED} If the auth manager was not properly initialized.
166
- * @public
167
- */
168
- authenticate( authMethod, authDetails ) {
169
- if ( !this.#initialized ) {
170
- throw exceptions.raise( exceptions.exceptionCode.E_GEN_NOT_INITIALIZED );
171
- }
172
- switch ( authMethod ) {
173
- case authMethodEnum.LOCAL:
174
- return this.#authenticateLocal( authDetails.username, authDetails.password );
175
- case authMethodEnum.OPENID_GOOGLE:
176
- return this.#authenticateOpenID( authDetails.baseUrl, this.#authSettings.oauth2.google, this.#clientConfigOAuth2Google );
177
- case authMethodEnum.OPENID_AZURE:
178
- return this.#authenticateOpenID( authDetails.baseUrl, this.#authSettings.oauth2.azure, this.#clientConfigOAuth2Azure );
179
- default: {
180
- throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD );
181
- }
182
- }
183
- }
184
-
185
- /**
186
- * Used to set up user authorization according to the specified authentication method.
187
- *
188
- * @method
189
- * @param {TiAuthMethod} authMethod
190
- * @param {URL} currentUrl
191
- * @param {Object} oidc
192
- * @returns {Promise<User>}
193
- * @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the authentication method is not recognized.
194
- * @public
195
- */
196
- authorize( authMethod, currentUrl, oidc ) {
197
- switch ( authMethod ) {
198
- case authMethodEnum.LOCAL:
199
- return Promise.resolve( new User( { userID: `local:${ tools.getUUID() }`, username: oidc.username } ) );
200
- case authMethodEnum.OPENID_GOOGLE:
201
- return this.#authorizeOpenID( currentUrl, oidc, this.#clientConfigOAuth2Google );
202
- case authMethodEnum.OPENID_AZURE:
203
- return this.#authorizeOpenID( currentUrl, oidc, this.#clientConfigOAuth2Azure );
204
- default: {
205
- throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD );
206
- }
207
- }
208
- }
209
-
210
- /**
211
- * Used to get the callback URL for the specified OAuth2 authentication method.
212
- *
213
- * @method
214
- * @param {TiAuthMethod} authMethod
215
- * @returns {string}
216
- * @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the requested OAuth2 method is not recognized or enabled.
217
- * @public
218
- */
219
- getOAuth2CallbackUrl( authMethod ) {
220
- if ( authMethod === authMethodEnum.OPENID_GOOGLE && this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
221
- return this.#authSettings.oauth2.google.callbackUrl;
222
- } else if ( authMethod === authMethodEnum.OPENID_AZURE && this.isAuthEnabled( authMethodEnum.OPENID_AZURE ) ) {
223
- return this.#authSettings.oauth2.azure.callbackUrl;
224
- } else {
225
- throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD );
226
- }
227
- }
228
-
229
- /**
230
- * Used to get the local route path of the callback for the specified OAuth2 authentication method.
231
- * <br/>
232
- * A callback can legitimately be configured either as a path or as the full absolute URL registered with the
233
- * identity provider. The absolute form is what the provider expects as the redirect URI, but it is not a usable
234
- * Express route pattern, so this reduces whatever is configured to the path the server must actually listen on.
235
- *
236
- * @method
237
- * @param {TiAuthMethod} authMethod
238
- * @returns {string|null} The route path, or null if the configured callback yields no usable path.
239
- * @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the requested OAuth2 method is not recognized or enabled.
240
- * @public
241
- */
242
- getOAuth2CallbackPath( authMethod ) {
243
- return AuthManager.toCallbackPath( this.getOAuth2CallbackUrl( authMethod ) );
244
- }
245
-
246
- /**
247
- * Reduces a configured OAuth2 callback value to the local route path it corresponds to. Accepts an absolute URL
248
- * ('https://host/login/azure-callback'), a protocol-relative URL, or a path with or without its leading slash,
249
- * and strips any query string or fragment. Pure and static; exposed for unit testing.
250
- * <br/>
251
- * NOTE: This exists because Express 5 parses a route pattern with path-to-regexp v8, where ':' opens a parameter
252
- * name — so an absolute URL used verbatim as a route path throws 'Missing parameter name' at startup.
253
- *
254
- * @method
255
- * @static
256
- * @param {string} callbackUrl
257
- * @returns {string|null} The route path, or null if no usable path can be derived.
258
- * @public
259
- */
260
- static toCallbackPath( callbackUrl ) {
261
- const value = String( callbackUrl || "" ).trim();
262
- if ( value === "" ) {
263
- return null;
264
- }
265
- try {
266
- // The base is only a parsing anchor — an absolute or protocol-relative value overrides it, while a
267
- // path or bare relative value resolves against it. Either way only the pathname is used.
268
- return new URL( value, "http://localhost" ).pathname;
269
- } catch {
270
- return null;
271
- }
272
- }
273
-
274
- /* Private interface */
275
-
276
- /**
277
- * Removes any OpenID Connect provider that is enabled but not configured (missing a client ID) from the set
278
- * of enabled authentication methods, logging a warning for each. This prevents a startup crash during OpenID
279
- * discovery when an enabled provider has no credentials (e.g. a container started without OAuth env vars): the
280
- * instance boots on its remaining methods, and `isAuthEnabled` then correctly reports the dropped provider as
281
- * unavailable so a sign-in attempt against it is rejected per-request instead of taking down startup.
282
- *
283
- * @method
284
- * @private
285
- */
286
- #dropUnconfiguredOpenIDProviders() {
287
- const providers = [
288
- { method: authMethodEnum.OPENID_GOOGLE, oauth2: this.#authSettings.oauth2.google, label: "Google" },
289
- { method: authMethodEnum.OPENID_AZURE, oauth2: this.#authSettings.oauth2.azure, label: "Azure" }
290
- ];
291
- providers.forEach( ( provider ) => {
292
- if ( this.isAuthEnabled( provider.method ) && !this.#isOpenIDConfigured( provider.oauth2 ) ) {
293
- this.#authSettings.enabledMethods = this.#authSettings.enabledMethods.filter( ( method ) => method !== provider.method );
294
- logger.log( `OpenID Connect (${ provider.label }) is enabled but not configured (missing client ID); skipping this provider.`, logger.logSeverity.WARNING );
295
- }
296
- } );
297
- }
298
-
299
- /**
300
- * Checks whether an OpenID Connect provider has the minimum configuration required to initialize (a non-empty client ID).
301
- *
302
- * @method
303
- * @param {SettingsOAuth2Client} [oauth2] The provider's OAuth2 settings.
304
- * @returns {boolean}
305
- * @private
306
- */
307
- #isOpenIDConfigured( oauth2 ) {
308
- return !!( oauth2 && typeof oauth2.clientID === "string" && oauth2.clientID.trim() !== "" );
309
- }
310
-
311
- /**
312
- * Used to initialize the OpenID Connect client for the specified OAuth2 authentication method.
313
- * <br/>
314
- * NOTE: A Google Cloud guide available here: https://developers.google.com/identity/openid-connect/openid-connect
315
- * <br/>
316
- * NOTE: An Azure Cloud guide available here: https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols
317
- *
318
- * @method
319
- * @param {SettingsOAuth2Client} oauth2
320
- * @returns {Promise<openidClient.Configuration>}
321
- * @private
322
- */
323
- #initializeOpenIDClient( oauth2 ) {
324
- return new Promise( ( resolve, reject ) => {
325
- // TODO: Public clients are not fully supported yet!
326
- let clientAuthentication;
327
- let metaData;
328
- if ( oauth2.isPublic === true ) {
329
- metaData = { token_endpoint_auth_method: openIDTokenEndpointAuthMethodEnum.NONE };
330
- clientAuthentication = openidClient.None();
331
- } else {
332
- const method = oauth2.tokenEndpointAuthMethod || openIDTokenEndpointAuthMethodEnum.POST;
333
- switch ( method ) {
334
- case openIDTokenEndpointAuthMethodEnum.POST: {
335
- clientAuthentication = openidClient.ClientSecretPost( oauth2.clientSecret );
336
- metaData = { token_endpoint_auth_method: openIDTokenEndpointAuthMethodEnum.POST };
337
- }
338
- break;
339
- case openIDTokenEndpointAuthMethodEnum.BASIC: {
340
- clientAuthentication = openidClient.ClientSecretBasic( oauth2.clientSecret );
341
- metaData = { token_endpoint_auth_method: openIDTokenEndpointAuthMethodEnum.BASIC };
342
- }
343
- break;
344
- default: {
345
- return reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD ) );
346
- }
347
- }
348
- }
349
-
350
- openidClient.discovery( new URL( oauth2.discoveryUrl ), oauth2.clientID, metaData, clientAuthentication, { algorithm: "oidc" } ).then( ( configuration ) => {
351
- resolve( configuration );
352
- } ).catch( ( error ) => {
353
- reject( exceptions.raise( error ) );
354
- } );
355
- } );
356
- }
357
-
358
- /**
359
- * Used to verify the local authentication of a request.
360
- *
361
- * @method
362
- * @param {string} username
363
- * @param {string} password
364
- * @returns {Promise}
365
- * @private
366
- */
367
- #authenticateLocal( username, password ) {
368
- return new Promise( ( resolve, reject ) => {
369
- // TODO: Implement this!
370
- if ( this.isAuthEnabled( authMethodEnum.LOCAL ) && ( username === this.#authSettings.local.username && password === this.#authSettings.local.password ) ) {
371
- resolve();
372
- } else {
373
- reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
374
- }
375
- } );
376
- }
377
-
378
- /**
379
- * Used to perform the actual OpenID Connect authentication.
380
- *
381
- * @method
382
- * @param {string} baseUrl
383
- * @param {SettingsOAuth2Client} oauth2
384
- * @param {openidClient.Configuration} clientConfig
385
- * @returns {Promise<Object>}
386
- * @private
387
- */
388
- #authenticateOpenID( baseUrl, oauth2, clientConfig ) {
389
- return new Promise( ( resolve, reject ) => {
390
- const codeVerifier = openidClient.randomPKCECodeVerifier();
391
- const nonce = ( typeof openidClient.randomNonce === "function" ) ? openidClient.randomNonce() : randomBytes( 16 ).toString( "base64" );
392
- const redirectUri = new URL( oauth2.callbackUrl, baseUrl ).toString();
393
- openidClient.calculatePKCECodeChallenge( codeVerifier ).then( ( codeChallenge ) => {
394
- const parameters = {
395
- redirect_uri: redirectUri,
396
- response_type: "code",
397
- scope: "openid email profile",
398
- state: openidClient.randomState(),
399
- code_challenge: codeChallenge,
400
- code_challenge_method: "S256",
401
- nonce: nonce
402
- };
403
- const redirectTo = openidClient.buildAuthorizationUrl( clientConfig, parameters );
404
- resolve( { redirectTo: redirectTo, codeVerifier: codeVerifier, state: parameters.state, nonce: nonce } );
405
- } ).catch( ( error ) => {
406
- reject( exceptions.raise( error ) );
407
- } );
408
- } );
409
- }
410
-
411
- /**
412
- * Used to perform the actual OpenID Connect authorization.
413
- *
414
- * @method
415
- * @param {URL} currentUrl
416
- * @param {Object} oidc
417
- * @param {openidClient.Configuration} clientConfig
418
- * @returns {Promise<User>}
419
- * @private
420
- */
421
- #authorizeOpenID( currentUrl, oidc, clientConfig ) {
422
- return new Promise( ( resolve, reject ) => {
423
- openidClient.authorizationCodeGrant( clientConfig, currentUrl, {
424
- pkceCodeVerifier: oidc.codeVerifier,
425
- expectedState: oidc.state,
426
- expectedNonce: oidc.nonce
427
- } ).then( ( token ) => {
428
- const claims = token.claims();
429
- return openidClient.fetchUserInfo( clientConfig, token.access_token, claims.sub );
430
- } ).then( ( userInfo ) => {
431
- const username = userInfo.preferred_username ?? userInfo.email ?? userInfo.name ?? `sub:${ userInfo.sub }`;
432
- resolve( new User( { userID: `oauth2:${ userInfo.sub }`, username: username, email: userInfo.email, name: userInfo.name } ) );
433
- } ).catch( ( error ) => {
434
- reject( exceptions.raise( error ) );
435
- } );
436
- } );
437
- }
438
-
439
- }
440
-
441
- module.exports = AuthManager;
442
- module.exports.authMethod = authMethodEnum;
1
+ /*
2
+ * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
+ * Copyright © 2021-2025 Boris Kostadinov <kostadinov.boris@gmail.com>
4
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
+ * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
+ */
8
+
9
+ const tools = require( "@ti-engine/core/tools" );
10
+ const logger = require( "@ti-engine/core/logger" );
11
+ const exceptions = require( "@ti-engine/core/exceptions" );
12
+ const { randomBytes } = require( "node:crypto" );
13
+ const openidClient = require( "openid-client" );
14
+ const User = require( "#user" );
15
+
16
+ /** @import { SettingsAuth } from "#web-server" */
17
+
18
+ /**
19
+ * Enum for specifying the authentication method.
20
+ *
21
+ * @readonly
22
+ * @enum {string}
23
+ * @typedef {string} TiAuthMethod
24
+ */
25
+ const authMethodEnum = tools.enum( {
26
+ LOCAL: [ "local", "local", "Local authentication with username and password." ],
27
+ OPENID_AZURE: [ "openid-azure", "openid-azure", "Authentication to Azure Cloud using OpenID Connect." ],
28
+ OPENID_GOOGLE: [ "openid-google", "openid-google", "Authentication to Google Cloud using OpenID Connect." ]
29
+ } );
30
+
31
+ /**
32
+ * Enum for specifying the OpenID Connect client authentication method.
33
+ *
34
+ * @readonly
35
+ * @enum {string}
36
+ * @typedef {string} TiTokenEndpointAuthMethod
37
+ */
38
+ const openIDTokenEndpointAuthMethodEnum = tools.enum( {
39
+ BASIC: [ "client_secret_basic", "basic", "Uses 'client_secret_basic' token endpoint authentication method." ],
40
+ POST: [ "client_secret_post", "post", "Uses 'client_secret_post' token endpoint authentication method." ],
41
+ NONE: [ "none", "none", "Uses 'none' token endpoint authentication method." ]
42
+ } );
43
+
44
+ /**
45
+ * The AuthManager class is used to manage authentication and authorization.
46
+ *
47
+ * @class AuthManager
48
+ * @public
49
+ */
50
+ class AuthManager {
51
+
52
+ #initialized = false;
53
+ /** @type {SettingsAuth} */
54
+ #authSettings = {
55
+ enabledMethods: [],
56
+ local: {
57
+ username: undefined,
58
+ password: undefined
59
+ },
60
+ oauth2: {}
61
+ };
62
+ #clientConfigOAuth2Google = {};
63
+ #clientConfigOAuth2Azure = {};
64
+
65
+ /**
66
+ * @constructor
67
+ * @param {SettingsAuth} settings
68
+ */
69
+ constructor( settings ) {
70
+ if ( settings ) {
71
+ this.#authSettings = settings;
72
+ }
73
+
74
+ // Set up local authentication configuration:
75
+ if ( this.isAuthEnabled( authMethodEnum.LOCAL ) ) {
76
+ // TODO: For testing purposes only! Implement real local auth later!
77
+ this.#authSettings.local = this.#authSettings.local || {};
78
+ this.#authSettings.local.username = "admin";
79
+ this.#authSettings.local.password = "admin";
80
+ }
81
+
82
+ // Set up OAuth2 configuration:
83
+ this.#authSettings.oauth2 = this.#authSettings.oauth2 || {};
84
+ if ( this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
85
+ this.#authSettings.oauth2.google = this.#authSettings.oauth2.google || {};
86
+ this.#authSettings.oauth2.google.clientID = process.env.TI_GCLOUD_AUTH_CLIENT_ID || this.#authSettings.oauth2.google.clientID;
87
+ this.#authSettings.oauth2.google.clientSecret = process.env.TI_GCLOUD_AUTH_CLIENT_SECRET || this.#authSettings.oauth2.google.clientSecret;
88
+ this.#authSettings.oauth2.google.callbackUrl = process.env.TI_GCLOUD_AUTH_CALLBACK_URL || this.#authSettings.oauth2.google.callbackUrl;
89
+ this.#authSettings.oauth2.google.discoveryUrl = process.env.TI_GCLOUD_AUTH_DISCOVERY_URL || this.#authSettings.oauth2.google.discoveryUrl;
90
+ }
91
+ if ( this.isAuthEnabled( authMethodEnum.OPENID_AZURE ) ) {
92
+ this.#authSettings.oauth2.azure = this.#authSettings.oauth2.azure || {};
93
+ this.#authSettings.oauth2.azure.clientID = process.env.TI_AZURE_AUTH_CLIENT_ID || this.#authSettings.oauth2.azure.clientID;
94
+ this.#authSettings.oauth2.azure.clientSecret = process.env.TI_AZURE_AUTH_CLIENT_SECRET || this.#authSettings.oauth2.azure.clientSecret;
95
+ this.#authSettings.oauth2.azure.callbackUrl = process.env.TI_AZURE_AUTH_CALLBACK_URL || this.#authSettings.oauth2.azure.callbackUrl;
96
+ this.#authSettings.oauth2.azure.discoveryUrl = process.env.TI_AZURE_AUTH_DISCOVERY_URL || this.#authSettings.oauth2.azure.discoveryUrl;
97
+ }
98
+ }
99
+
100
+ /* Public interface */
101
+
102
+ /**
103
+ * Used to initialize the authentication manager.
104
+ *
105
+ * @method
106
+ * @returns {Promise}
107
+ * @public
108
+ */
109
+ initialize() {
110
+ // Drop any OpenID Connect provider that is enabled but not configured (missing a client ID) so the
111
+ // instance boots with the remaining methods instead of crashing during discovery — e.g. a container
112
+ // started without OAuth credentials falls back to whatever else is enabled rather than failing to start.
113
+ this.#dropUnconfiguredOpenIDProviders();
114
+
115
+ let promises = [];
116
+ if ( this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
117
+ promises.push( this.#initializeOpenIDClient( this.#authSettings.oauth2.google ).then( ( configuration ) => {
118
+ this.#clientConfigOAuth2Google = configuration;
119
+ logger.log( "Enabled OpenID Connect authentication with Google Cloud.", logger.logSeverity.NOTICE );
120
+ } ) );
121
+ }
122
+ if ( this.isAuthEnabled( authMethodEnum.OPENID_AZURE ) ) {
123
+ promises.push( this.#initializeOpenIDClient( this.#authSettings.oauth2.azure ).then( ( configuration ) => {
124
+ this.#clientConfigOAuth2Azure = configuration;
125
+ logger.log( "Enabled OpenID Connect authentication with Azure Cloud.", logger.logSeverity.NOTICE );
126
+ } ) );
127
+ }
128
+
129
+ return Promise.all( promises ).then( () => {
130
+ this.#initialized = true;
131
+ } );
132
+ }
133
+
134
+ /**
135
+ * Used to check whether the specified authentication method is enabled.
136
+ *
137
+ * @method
138
+ * @param {TiAuthMethod} authMethod
139
+ * @returns {boolean}
140
+ * @public
141
+ */
142
+ isAuthEnabled( authMethod ) {
143
+ return this.#authSettings.enabledMethods.includes( authMethod );
144
+ }
145
+
146
+ /**
147
+ * Returns the list of currently enabled authentication methods, reflecting any OpenID providers dropped by
148
+ * {@link AuthManager#initialize} for being enabled but unconfigured. Callers (e.g. the login-page renderer)
149
+ * use this to present only the methods a user can actually complete.
150
+ *
151
+ * @method
152
+ * @returns {TiAuthMethod[]}
153
+ * @public
154
+ */
155
+ getEnabledMethods() {
156
+ return [ ...this.#authSettings.enabledMethods ];
157
+ }
158
+
159
+ /**
160
+ * Used to authenticate a user via the specified authentication method.
161
+ *
162
+ * @method
163
+ * @param {TiAuthMethod} authMethod
164
+ * @param {Object} authDetails
165
+ * @returns {Promise<Object>}
166
+ * @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the authentication method is not recognized or enabled.
167
+ * @throws {TiException.E_GEN_NOT_INITIALIZED} If the auth manager was not properly initialized.
168
+ * @public
169
+ */
170
+ authenticate( authMethod, authDetails ) {
171
+ if ( !this.#initialized ) {
172
+ throw exceptions.raise( exceptions.exceptionCode.E_GEN_NOT_INITIALIZED );
173
+ }
174
+ switch ( authMethod ) {
175
+ case authMethodEnum.LOCAL:
176
+ return this.#authenticateLocal( authDetails.username, authDetails.password );
177
+ case authMethodEnum.OPENID_GOOGLE:
178
+ return this.#authenticateOpenID( authDetails.baseUrl, this.#authSettings.oauth2.google, this.#clientConfigOAuth2Google );
179
+ case authMethodEnum.OPENID_AZURE:
180
+ return this.#authenticateOpenID( authDetails.baseUrl, this.#authSettings.oauth2.azure, this.#clientConfigOAuth2Azure );
181
+ default: {
182
+ throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD );
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Used to set up user authorization according to the specified authentication method.
189
+ *
190
+ * @method
191
+ * @param {TiAuthMethod} authMethod
192
+ * @param {URL} currentUrl
193
+ * @param {Object} oidc
194
+ * @returns {Promise<User>}
195
+ * @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the authentication method is not recognized.
196
+ * @public
197
+ */
198
+ authorize( authMethod, currentUrl, oidc ) {
199
+ switch ( authMethod ) {
200
+ case authMethodEnum.LOCAL:
201
+ return Promise.resolve( new User( { userID: `local:${ tools.getUUID() }`, username: oidc.username } ) );
202
+ case authMethodEnum.OPENID_GOOGLE:
203
+ return this.#authorizeOpenID( currentUrl, oidc, this.#clientConfigOAuth2Google );
204
+ case authMethodEnum.OPENID_AZURE:
205
+ return this.#authorizeOpenID( currentUrl, oidc, this.#clientConfigOAuth2Azure );
206
+ default: {
207
+ throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD );
208
+ }
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Used to get the callback URL for the specified OAuth2 authentication method.
214
+ *
215
+ * @method
216
+ * @param {TiAuthMethod} authMethod
217
+ * @returns {string}
218
+ * @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the requested OAuth2 method is not recognized or enabled.
219
+ * @public
220
+ */
221
+ getOAuth2CallbackUrl( authMethod ) {
222
+ if ( authMethod === authMethodEnum.OPENID_GOOGLE && this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
223
+ return this.#authSettings.oauth2.google.callbackUrl;
224
+ } else if ( authMethod === authMethodEnum.OPENID_AZURE && this.isAuthEnabled( authMethodEnum.OPENID_AZURE ) ) {
225
+ return this.#authSettings.oauth2.azure.callbackUrl;
226
+ } else {
227
+ throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD );
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Used to get the local route path of the callback for the specified OAuth2 authentication method.
233
+ * <br/>
234
+ * A callback can legitimately be configured either as a path or as the full absolute URL registered with the
235
+ * identity provider. The absolute form is what the provider expects as the redirect URI, but it is not a usable
236
+ * Express route pattern, so this reduces whatever is configured to the path the server must actually listen on.
237
+ *
238
+ * @method
239
+ * @param {TiAuthMethod} authMethod
240
+ * @returns {string|null} The route path, or null if the configured callback yields no usable path.
241
+ * @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the requested OAuth2 method is not recognized or enabled.
242
+ * @public
243
+ */
244
+ getOAuth2CallbackPath( authMethod ) {
245
+ return AuthManager.toCallbackPath( this.getOAuth2CallbackUrl( authMethod ) );
246
+ }
247
+
248
+ /**
249
+ * Reduces a configured OAuth2 callback value to the local route path it corresponds to. Accepts an absolute URL
250
+ * ('https://host/login/azure-callback'), a protocol-relative URL, or a path with or without its leading slash,
251
+ * and strips any query string or fragment. Pure and static; exposed for unit testing.
252
+ * <br/>
253
+ * NOTE: This exists because Express 5 parses a route pattern with path-to-regexp v8, where ':' opens a parameter
254
+ * name — so an absolute URL used verbatim as a route path throws 'Missing parameter name' at startup.
255
+ *
256
+ * @method
257
+ * @static
258
+ * @param {string} callbackUrl
259
+ * @returns {string|null} The route path, or null if no usable path can be derived.
260
+ * @public
261
+ */
262
+ static toCallbackPath( callbackUrl ) {
263
+ const value = String( callbackUrl || "" ).trim();
264
+ if ( value === "" ) {
265
+ return null;
266
+ }
267
+ try {
268
+ // The base is only a parsing anchor — an absolute or protocol-relative value overrides it, while a
269
+ // path or bare relative value resolves against it. Either way only the pathname is used.
270
+ return new URL( value, "http://localhost" ).pathname;
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+
276
+ /* Private interface */
277
+
278
+ /**
279
+ * Removes any OpenID Connect provider that is enabled but not configured (missing a client ID) from the set
280
+ * of enabled authentication methods, logging a warning for each. This prevents a startup crash during OpenID
281
+ * discovery when an enabled provider has no credentials (e.g. a container started without OAuth env vars): the
282
+ * instance boots on its remaining methods, and `isAuthEnabled` then correctly reports the dropped provider as
283
+ * unavailable so a sign-in attempt against it is rejected per-request instead of taking down startup.
284
+ *
285
+ * @method
286
+ */
287
+ #dropUnconfiguredOpenIDProviders() {
288
+ const providers = [
289
+ { method: authMethodEnum.OPENID_GOOGLE, oauth2: this.#authSettings.oauth2.google, label: "Google" },
290
+ { method: authMethodEnum.OPENID_AZURE, oauth2: this.#authSettings.oauth2.azure, label: "Azure" }
291
+ ];
292
+ providers.forEach( ( provider ) => {
293
+ if ( this.isAuthEnabled( provider.method ) && !this.#isOpenIDConfigured( provider.oauth2 ) ) {
294
+ this.#authSettings.enabledMethods = this.#authSettings.enabledMethods.filter( ( method ) => method !== provider.method );
295
+ logger.log( `OpenID Connect (${ provider.label }) is enabled but not configured (missing client ID); skipping this provider.`, logger.logSeverity.WARNING );
296
+ }
297
+ } );
298
+ }
299
+
300
+ /**
301
+ * Checks whether an OpenID Connect provider has the minimum configuration required to initialize (a non-empty client ID).
302
+ *
303
+ * @method
304
+ * @param {SettingsOAuth2Client} [oauth2] The provider's OAuth2 settings.
305
+ * @returns {boolean}
306
+ */
307
+ #isOpenIDConfigured( oauth2 ) {
308
+ return !!( oauth2 && typeof oauth2.clientID === "string" && oauth2.clientID.trim() !== "" );
309
+ }
310
+
311
+ /**
312
+ * Used to initialize the OpenID Connect client for the specified OAuth2 authentication method.
313
+ * <br/>
314
+ * NOTE: A Google Cloud guide available here: https://developers.google.com/identity/openid-connect/openid-connect
315
+ * <br/>
316
+ * NOTE: An Azure Cloud guide available here: https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols
317
+ *
318
+ * @method
319
+ * @param {SettingsOAuth2Client} oauth2
320
+ * @returns {Promise<openidClient.Configuration>}
321
+ */
322
+ #initializeOpenIDClient( oauth2 ) {
323
+ return new Promise( ( resolve, reject ) => {
324
+ // TODO: Public clients are not fully supported yet!
325
+ let clientAuthentication;
326
+ let metaData;
327
+ if ( oauth2.isPublic === true ) {
328
+ metaData = { token_endpoint_auth_method: openIDTokenEndpointAuthMethodEnum.NONE };
329
+ clientAuthentication = openidClient.None();
330
+ } else {
331
+ const method = oauth2.tokenEndpointAuthMethod || openIDTokenEndpointAuthMethodEnum.POST;
332
+ switch ( method ) {
333
+ case openIDTokenEndpointAuthMethodEnum.POST: {
334
+ clientAuthentication = openidClient.ClientSecretPost( oauth2.clientSecret );
335
+ metaData = { token_endpoint_auth_method: openIDTokenEndpointAuthMethodEnum.POST };
336
+ }
337
+ break;
338
+ case openIDTokenEndpointAuthMethodEnum.BASIC: {
339
+ clientAuthentication = openidClient.ClientSecretBasic( oauth2.clientSecret );
340
+ metaData = { token_endpoint_auth_method: openIDTokenEndpointAuthMethodEnum.BASIC };
341
+ }
342
+ break;
343
+ default: {
344
+ return reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD ) );
345
+ }
346
+ }
347
+ }
348
+
349
+ openidClient.discovery( new URL( oauth2.discoveryUrl ), oauth2.clientID, metaData, clientAuthentication, { algorithm: "oidc" } ).then( ( configuration ) => {
350
+ resolve( configuration );
351
+ } ).catch( ( error ) => {
352
+ reject( exceptions.raise( error ) );
353
+ } );
354
+ } );
355
+ }
356
+
357
+ /**
358
+ * Used to verify the local authentication of a request.
359
+ *
360
+ * @method
361
+ * @param {string} username
362
+ * @param {string} password
363
+ * @returns {Promise}
364
+ */
365
+ #authenticateLocal( username, password ) {
366
+ return new Promise( ( resolve, reject ) => {
367
+ // TODO: Implement this!
368
+ if ( this.isAuthEnabled( authMethodEnum.LOCAL ) && ( username === this.#authSettings.local.username && password === this.#authSettings.local.password ) ) {
369
+ resolve();
370
+ } else {
371
+ reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
372
+ }
373
+ } );
374
+ }
375
+
376
+ /**
377
+ * Used to perform the actual OpenID Connect authentication.
378
+ *
379
+ * @method
380
+ * @param {string} baseUrl
381
+ * @param {SettingsOAuth2Client} oauth2
382
+ * @param {openidClient.Configuration} clientConfig
383
+ * @returns {Promise<Object>}
384
+ */
385
+ #authenticateOpenID( baseUrl, oauth2, clientConfig ) {
386
+ return new Promise( ( resolve, reject ) => {
387
+ const codeVerifier = openidClient.randomPKCECodeVerifier();
388
+ const nonce = ( typeof openidClient.randomNonce === "function" ) ? openidClient.randomNonce() : randomBytes( 16 ).toString( "base64" );
389
+ const redirectUri = new URL( oauth2.callbackUrl, baseUrl ).toString();
390
+ openidClient.calculatePKCECodeChallenge( codeVerifier ).then( ( codeChallenge ) => {
391
+ const parameters = {
392
+ redirect_uri: redirectUri,
393
+ response_type: "code",
394
+ scope: "openid email profile",
395
+ state: openidClient.randomState(),
396
+ code_challenge: codeChallenge,
397
+ code_challenge_method: "S256",
398
+ nonce: nonce
399
+ };
400
+ const redirectTo = openidClient.buildAuthorizationUrl( clientConfig, parameters );
401
+ resolve( { redirectTo: redirectTo, codeVerifier: codeVerifier, state: parameters.state, nonce: nonce } );
402
+ } ).catch( ( error ) => {
403
+ reject( exceptions.raise( error ) );
404
+ } );
405
+ } );
406
+ }
407
+
408
+ /**
409
+ * Used to perform the actual OpenID Connect authorization.
410
+ *
411
+ * @method
412
+ * @param {URL} currentUrl
413
+ * @param {Object} oidc
414
+ * @param {openidClient.Configuration} clientConfig
415
+ * @returns {Promise<User>}
416
+ */
417
+ #authorizeOpenID( currentUrl, oidc, clientConfig ) {
418
+ return new Promise( ( resolve, reject ) => {
419
+ openidClient.authorizationCodeGrant( clientConfig, currentUrl, {
420
+ pkceCodeVerifier: oidc.codeVerifier,
421
+ expectedState: oidc.state,
422
+ expectedNonce: oidc.nonce
423
+ } ).then( ( token ) => {
424
+ const claims = token.claims();
425
+ return openidClient.fetchUserInfo( clientConfig, token.access_token, claims.sub );
426
+ } ).then( ( userInfo ) => {
427
+ const username = userInfo.preferred_username ?? userInfo.email ?? userInfo.name ?? `sub:${ userInfo.sub }`;
428
+ resolve( new User( { userID: `oauth2:${ userInfo.sub }`, username: username, email: userInfo.email, name: userInfo.name } ) );
429
+ } ).catch( ( error ) => {
430
+ reject( exceptions.raise( error ) );
431
+ } );
432
+ } );
433
+ }
434
+
435
+ }
436
+
437
+ module.exports = AuthManager;
438
+ AuthManager.authMethod = authMethodEnum;