@ti-engine/web-framework 1.19.0 → 1.19.1
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/.env +4 -4
- package/CHANGELOG.md +362 -353
- package/README.md +73 -73
- package/bin/build/post-install.js +18 -18
- package/bin/localization/web-server-labels.json +27 -27
- package/bin/static/.well-known/appspecific/com.chrome.devtools.json +5 -5
- package/bin/static/fragments/components/component-notification-bar.html +21 -21
- package/bin/static/fragments/components/component-sidebar.html +33 -33
- package/bin/static/fragments/components/component-tooltip.html +10 -10
- package/bin/static/fragments/components/component-topbar.html +5 -5
- package/bin/static/fragments/frame-administration.html +2 -2
- package/bin/static/fragments/frame-application.html +18 -18
- package/bin/static/fragments/frame-dashboard.html +2 -2
- package/bin/static/fragments/frame-login.html +119 -119
- package/bin/static/fragments/frame-not-found.html +2 -2
- package/bin/static/fragments/frame-profile.html +2 -2
- package/bin/static/index.html +22 -22
- package/bin/static/scripts/ti-charts.js +1591 -1591
- package/bin/static/scripts/ti-framework.css +3194 -3194
- package/bin/static/scripts/ti-framework.js +1427 -1427
- package/bin/static/scripts/ti-theme-black-glass.css +216 -216
- package/bin/static/scripts/ti-theme-daylight.css +87 -87
- package/bin/web-app-manager.js +663 -663
- package/bin/web-server.js +936 -936
- package/bin/web-server.json +48 -48
- package/components/admin-config-handlers.js +92 -92
- package/components/auth-manager.js +441 -441
- package/components/authorization.js +135 -135
- package/components/config-change-notifier.js +98 -98
- package/components/config-registry.js +260 -260
- package/components/config-service.js +360 -360
- package/components/config-store.js +246 -246
- package/components/definitions.types.js +26 -26
- package/components/session-store.js +110 -110
- package/components/user.js +132 -132
- package/components/web-config-env.js +85 -85
- package/components/web-handlers.js +800 -800
- package/package.json +76 -67
|
@@ -1,442 +1,442 @@
|
|
|
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;
|
|
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
442
|
module.exports.authMethod = authMethodEnum;
|