@ti-engine/web-framework 1.13.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 -0
- package/CHANGELOG.md +274 -0
- package/README.md +26 -0
- package/bin/build/post-install.js +18 -0
- package/bin/localization/web-server-labels.json +28 -0
- package/bin/static/.well-known/appspecific/com.chrome.devtools.json +6 -0
- package/bin/static/favicon.ico +0 -0
- package/bin/static/fragments/components/component-notification-bar.html +21 -0
- package/bin/static/fragments/components/component-sidebar-flyout.html +37 -0
- package/bin/static/fragments/components/component-sidebar.html +33 -0
- package/bin/static/fragments/components/component-tooltip.html +11 -0
- package/bin/static/fragments/components/component-topbar.html +6 -0
- package/bin/static/fragments/frame-administration.html +3 -0
- package/bin/static/fragments/frame-application.html +19 -0
- package/bin/static/fragments/frame-dashboard.html +3 -0
- package/bin/static/fragments/frame-login.html +105 -0
- package/bin/static/fragments/frame-not-found.html +3 -0
- package/bin/static/fragments/frame-profile.html +3 -0
- package/bin/static/index.html +23 -0
- package/bin/static/scripts/lib/alpinejs-csp.min.js +7 -0
- package/bin/static/scripts/lib/htmx.min.js +1 -0
- package/bin/static/scripts/lib/safe-nonce.min.js +1 -0
- package/bin/static/scripts/ti-charts.js +1591 -0
- package/bin/static/scripts/ti-framework.css +3195 -0
- package/bin/static/scripts/ti-framework.js +1427 -0
- package/bin/static/scripts/ti-theme-black-glass.css +216 -0
- package/bin/static/scripts/ti-theme-daylight.css +87 -0
- package/bin/web-app-manager.js +564 -0
- package/bin/web-server.js +604 -0
- package/bin/web-server.json +49 -0
- package/components/admin-config-handlers.js +92 -0
- package/components/auth-manager.js +344 -0
- package/components/authorization.js +135 -0
- package/components/config-change-notifier.js +98 -0
- package/components/config-registry.js +246 -0
- package/components/config-service.js +349 -0
- package/components/config-store.js +246 -0
- package/components/definitions.types.js +26 -0
- package/components/session-store.js +111 -0
- package/components/user.js +133 -0
- package/components/web-handlers.js +765 -0
- package/package.json +66 -0
|
@@ -0,0 +1,92 @@
|
|
|
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-2026 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
|
+
/**
|
|
10
|
+
* Express handler factories for the admin configuration-management API. Each factory takes the {@link ConfigService}
|
|
11
|
+
* to delegate to (injected so the handlers are unit-testable), and returns an Express middleware that responds in the
|
|
12
|
+
* framework's convention — `{ isSuccessful: true, data }` on success — forwarding errors to the error middleware.
|
|
13
|
+
* Validation failures come back as `data` (`{ ok:false, errors }`) so the UI can render field-level messages; a
|
|
14
|
+
* version conflict maps to `409` and an unknown editor/document/change-set to `404`. These routes are gated by the
|
|
15
|
+
* `requireAdmin` guard and inherit the server's global auth + CSRF middleware.
|
|
16
|
+
*
|
|
17
|
+
* @module admin-config-handlers
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const exceptions = require( "@ti-engine/core/exceptions" );
|
|
21
|
+
|
|
22
|
+
function sendData( response, data ) {
|
|
23
|
+
response.set( "Cache-Control", "no-store" );
|
|
24
|
+
response.set( "Content-Type", "application/json; charset=utf-8" );
|
|
25
|
+
response.status( exceptions.httpCode.C_200 ).send( { isSuccessful: true, data: data } );
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function forward( next, error ) {
|
|
29
|
+
const reason = error && error.data && error.data.reason;
|
|
30
|
+
const raised = exceptions.raise( error );
|
|
31
|
+
if ( reason === "version-conflict" ) {
|
|
32
|
+
raised.httpCode = exceptions.httpCode.C_409;
|
|
33
|
+
} else if ( reason === "unknown-editor" || reason === "unknown-changeset" || reason === "unknown-config" ) {
|
|
34
|
+
raised.httpCode = exceptions.httpCode.C_404;
|
|
35
|
+
}
|
|
36
|
+
next( raised );
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function adminID( request ) {
|
|
40
|
+
return ( request.session && request.session.user ) ? request.session.user.userID : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {ConfigService} service
|
|
45
|
+
* @returns {ExpressHandler}
|
|
46
|
+
*/
|
|
47
|
+
module.exports.listEditors = ( service ) => ( request, response, next ) => {
|
|
48
|
+
try {
|
|
49
|
+
sendData( response, service.listEditors() );
|
|
50
|
+
} catch ( error ) {
|
|
51
|
+
forward( next, error );
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
module.exports.composeView = ( service ) => ( request, response, next ) => {
|
|
56
|
+
service.composeView( request.params.editorKey ).then( ( view ) => sendData( response, view ) ).catch( ( error ) => forward( next, error ) );
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
module.exports.saveEditorEdit = ( service ) => ( request, response, next ) => {
|
|
60
|
+
const body = request.body || {};
|
|
61
|
+
service.saveEditorEdit( request.params.editorKey, body.edited, { adminID: adminID( request ), note: body.note }, body.expectedVersions ).then( ( result ) => sendData( response, result ) ).catch( ( error ) => forward( next, error ) );
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
module.exports.getCurrent = ( service ) => ( request, response, next ) => {
|
|
65
|
+
service.getCurrent( request.params.configKey ).then( ( current ) => sendData( response, current ) ).catch( ( error ) => forward( next, error ) );
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
module.exports.getHistory = ( service ) => ( request, response, next ) => {
|
|
69
|
+
service.getHistory( request.params.configKey ).then( ( history ) => sendData( response, history ) ).catch( ( error ) => forward( next, error ) );
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
module.exports.listChanges = ( service ) => ( request, response, next ) => {
|
|
73
|
+
service.listChanges().then( ( changes ) => sendData( response, changes ) ).catch( ( error ) => forward( next, error ) );
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
module.exports.getChange = ( service ) => ( request, response, next ) => {
|
|
77
|
+
service.getChange( request.params.changeSetID ).then( ( change ) => sendData( response, change ) ).catch( ( error ) => forward( next, error ) );
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
module.exports.restoreChangeSet = ( service ) => ( request, response, next ) => {
|
|
81
|
+
const body = request.body || {};
|
|
82
|
+
service.restoreChangeSet( request.params.changeSetID, { adminID: adminID( request ), note: body.note } ).then( ( result ) => sendData( response, result ) ).catch( ( error ) => forward( next, error ) );
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
module.exports.exportBundle = ( service ) => ( request, response, next ) => {
|
|
86
|
+
service.exportBundle( { adminID: adminID( request ) } ).then( ( bundle ) => {
|
|
87
|
+
response.set( "Cache-Control", "no-store" );
|
|
88
|
+
response.set( "Content-Type", "application/json; charset=utf-8" );
|
|
89
|
+
response.set( "Content-Disposition", "attachment; filename=\"config-export.json\"" );
|
|
90
|
+
response.status( exceptions.httpCode.C_200 ).send( JSON.stringify( bundle, null, 2 ) );
|
|
91
|
+
} ).catch( ( error ) => forward( next, error ) );
|
|
92
|
+
};
|
|
@@ -0,0 +1,344 @@
|
|
|
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
|
+
let promises = [];
|
|
109
|
+
if ( this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
|
|
110
|
+
promises.push( this.#initializeOpenIDClient( this.#authSettings.oauth2.google ).then( ( configuration ) => {
|
|
111
|
+
this.#clientConfigOAuth2Google = configuration;
|
|
112
|
+
logger.log( "Enabled OpenID Connect authentication with Google Cloud.", logger.logSeverity.NOTICE );
|
|
113
|
+
} ) );
|
|
114
|
+
}
|
|
115
|
+
if ( this.isAuthEnabled( authMethodEnum.OPENID_AZURE ) ) {
|
|
116
|
+
promises.push( this.#initializeOpenIDClient( this.#authSettings.oauth2.azure ).then( ( configuration ) => {
|
|
117
|
+
this.#clientConfigOAuth2Azure = configuration;
|
|
118
|
+
logger.log( "Enabled OpenID Connect authentication with Azure Cloud.", logger.logSeverity.NOTICE );
|
|
119
|
+
} ) );
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return Promise.all( promises ).then( () => {
|
|
123
|
+
this.#initialized = true;
|
|
124
|
+
} );
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Used to check whether the specified authentication method is enabled.
|
|
129
|
+
*
|
|
130
|
+
* @method
|
|
131
|
+
* @param {TiAuthMethod} authMethod
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
* @public
|
|
134
|
+
*/
|
|
135
|
+
isAuthEnabled( authMethod ) {
|
|
136
|
+
return this.#authSettings.enabledMethods.includes( authMethod );
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Used to authenticate a user via the specified authentication method.
|
|
141
|
+
*
|
|
142
|
+
* @method
|
|
143
|
+
* @param {TiAuthMethod} authMethod
|
|
144
|
+
* @param {Object} authDetails
|
|
145
|
+
* @returns {Promise<Object>}
|
|
146
|
+
* @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the authentication method is not recognized or enabled.
|
|
147
|
+
* @throws {TiException.E_GEN_NOT_INITIALIZED} If the auth manager was not properly initialized.
|
|
148
|
+
* @public
|
|
149
|
+
*/
|
|
150
|
+
authenticate( authMethod, authDetails ) {
|
|
151
|
+
if ( !this.#initialized ) {
|
|
152
|
+
throw exceptions.raise( exceptions.exceptionCode.E_GEN_NOT_INITIALIZED );
|
|
153
|
+
}
|
|
154
|
+
switch ( authMethod ) {
|
|
155
|
+
case authMethodEnum.LOCAL:
|
|
156
|
+
return this.#authenticateLocal( authDetails.username, authDetails.password );
|
|
157
|
+
case authMethodEnum.OPENID_GOOGLE:
|
|
158
|
+
return this.#authenticateOpenID( authDetails.baseUrl, this.#authSettings.oauth2.google, this.#clientConfigOAuth2Google );
|
|
159
|
+
case authMethodEnum.OPENID_AZURE:
|
|
160
|
+
return this.#authenticateOpenID( authDetails.baseUrl, this.#authSettings.oauth2.azure, this.#clientConfigOAuth2Azure );
|
|
161
|
+
default: {
|
|
162
|
+
throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD );
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Used to set up user authorization according to the specified authentication method.
|
|
169
|
+
*
|
|
170
|
+
* @method
|
|
171
|
+
* @param {TiAuthMethod} authMethod
|
|
172
|
+
* @param {URL} currentUrl
|
|
173
|
+
* @param {Object} oidc
|
|
174
|
+
* @returns {Promise<User>}
|
|
175
|
+
* @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the authentication method is not recognized.
|
|
176
|
+
* @public
|
|
177
|
+
*/
|
|
178
|
+
authorize( authMethod, currentUrl, oidc ) {
|
|
179
|
+
switch ( authMethod ) {
|
|
180
|
+
case authMethodEnum.LOCAL:
|
|
181
|
+
return Promise.resolve( new User( { userID: `local:${ tools.getUUID() }`, username: oidc.username } ) );
|
|
182
|
+
case authMethodEnum.OPENID_GOOGLE:
|
|
183
|
+
return this.#authorizeOpenID( currentUrl, oidc, this.#clientConfigOAuth2Google );
|
|
184
|
+
case authMethodEnum.OPENID_AZURE:
|
|
185
|
+
return this.#authorizeOpenID( currentUrl, oidc, this.#clientConfigOAuth2Azure );
|
|
186
|
+
default: {
|
|
187
|
+
throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD );
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Used to get the callback URL for the specified OAuth2 authentication method.
|
|
194
|
+
*
|
|
195
|
+
* @method
|
|
196
|
+
* @param {TiAuthMethod} authMethod
|
|
197
|
+
* @returns {string}
|
|
198
|
+
* @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the requested OAuth2 method is not recognized or enabled.
|
|
199
|
+
* @public
|
|
200
|
+
*/
|
|
201
|
+
getOAuth2CallbackUrl( authMethod ) {
|
|
202
|
+
if ( authMethod === authMethodEnum.OPENID_GOOGLE && this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
|
|
203
|
+
return this.#authSettings.oauth2.google.callbackUrl;
|
|
204
|
+
} else if ( authMethod === authMethodEnum.OPENID_AZURE && this.isAuthEnabled( authMethodEnum.OPENID_AZURE ) ) {
|
|
205
|
+
return this.#authSettings.oauth2.azure.callbackUrl;
|
|
206
|
+
} else {
|
|
207
|
+
throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD );
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* Private interface */
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Used to initialize the OpenID Connect client for the specified OAuth2 authentication method.
|
|
215
|
+
* <br/>
|
|
216
|
+
* NOTE: A Google Cloud guide available here: https://developers.google.com/identity/openid-connect/openid-connect
|
|
217
|
+
* <br/>
|
|
218
|
+
* NOTE: An Azure Cloud guide available here: https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols
|
|
219
|
+
*
|
|
220
|
+
* @method
|
|
221
|
+
* @param {SettingsOAuth2Client} oauth2
|
|
222
|
+
* @returns {Promise<openidClient.Configuration>}
|
|
223
|
+
* @private
|
|
224
|
+
*/
|
|
225
|
+
#initializeOpenIDClient( oauth2 ) {
|
|
226
|
+
return new Promise( ( resolve, reject ) => {
|
|
227
|
+
// TODO: Public clients are not fully supported yet!
|
|
228
|
+
let clientAuthentication;
|
|
229
|
+
let metaData;
|
|
230
|
+
if ( oauth2.isPublic === true ) {
|
|
231
|
+
metaData = { token_endpoint_auth_method: openIDTokenEndpointAuthMethodEnum.NONE };
|
|
232
|
+
clientAuthentication = openidClient.None();
|
|
233
|
+
} else {
|
|
234
|
+
const method = oauth2.tokenEndpointAuthMethod || openIDTokenEndpointAuthMethodEnum.POST;
|
|
235
|
+
switch ( method ) {
|
|
236
|
+
case openIDTokenEndpointAuthMethodEnum.POST: {
|
|
237
|
+
clientAuthentication = openidClient.ClientSecretPost( oauth2.clientSecret );
|
|
238
|
+
metaData = { token_endpoint_auth_method: openIDTokenEndpointAuthMethodEnum.POST };
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
case openIDTokenEndpointAuthMethodEnum.BASIC: {
|
|
242
|
+
clientAuthentication = openidClient.ClientSecretBasic( oauth2.clientSecret );
|
|
243
|
+
metaData = { token_endpoint_auth_method: openIDTokenEndpointAuthMethodEnum.BASIC };
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
default: {
|
|
247
|
+
return reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD ) );
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
openidClient.discovery( new URL( oauth2.discoveryUrl ), oauth2.clientID, metaData, clientAuthentication, { algorithm: "oidc" } ).then( ( configuration ) => {
|
|
253
|
+
resolve( configuration );
|
|
254
|
+
} ).catch( ( error ) => {
|
|
255
|
+
reject( exceptions.raise( error ) );
|
|
256
|
+
} );
|
|
257
|
+
} );
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Used to verify the local authentication of a request.
|
|
262
|
+
*
|
|
263
|
+
* @method
|
|
264
|
+
* @param {string} username
|
|
265
|
+
* @param {string} password
|
|
266
|
+
* @returns {Promise}
|
|
267
|
+
* @private
|
|
268
|
+
*/
|
|
269
|
+
#authenticateLocal( username, password ) {
|
|
270
|
+
return new Promise( ( resolve, reject ) => {
|
|
271
|
+
// TODO: Implement this!
|
|
272
|
+
if ( this.isAuthEnabled( authMethodEnum.LOCAL ) && ( username === this.#authSettings.local.username && password === this.#authSettings.local.password ) ) {
|
|
273
|
+
resolve();
|
|
274
|
+
} else {
|
|
275
|
+
reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
|
|
276
|
+
}
|
|
277
|
+
} );
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Used to perform the actual OpenID Connect authentication.
|
|
282
|
+
*
|
|
283
|
+
* @method
|
|
284
|
+
* @param {string} baseUrl
|
|
285
|
+
* @param {SettingsOAuth2Client} oauth2
|
|
286
|
+
* @param {openidClient.Configuration} clientConfig
|
|
287
|
+
* @returns {Promise<Object>}
|
|
288
|
+
* @private
|
|
289
|
+
*/
|
|
290
|
+
#authenticateOpenID( baseUrl, oauth2, clientConfig ) {
|
|
291
|
+
return new Promise( ( resolve, reject ) => {
|
|
292
|
+
const codeVerifier = openidClient.randomPKCECodeVerifier();
|
|
293
|
+
const nonce = ( typeof openidClient.randomNonce === "function" ) ? openidClient.randomNonce() : randomBytes( 16 ).toString( "base64" );
|
|
294
|
+
const redirectUri = new URL( oauth2.callbackUrl, baseUrl ).toString();
|
|
295
|
+
openidClient.calculatePKCECodeChallenge( codeVerifier ).then( ( codeChallenge ) => {
|
|
296
|
+
const parameters = {
|
|
297
|
+
redirect_uri: redirectUri,
|
|
298
|
+
response_type: "code",
|
|
299
|
+
scope: "openid email profile",
|
|
300
|
+
state: openidClient.randomState(),
|
|
301
|
+
code_challenge: codeChallenge,
|
|
302
|
+
code_challenge_method: "S256",
|
|
303
|
+
nonce: nonce
|
|
304
|
+
};
|
|
305
|
+
const redirectTo = openidClient.buildAuthorizationUrl( clientConfig, parameters );
|
|
306
|
+
resolve( { redirectTo: redirectTo, codeVerifier: codeVerifier, state: parameters.state, nonce: nonce } );
|
|
307
|
+
} ).catch( ( error ) => {
|
|
308
|
+
reject( exceptions.raise( error ) );
|
|
309
|
+
} );
|
|
310
|
+
} );
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Used to perform the actual OpenID Connect authorization.
|
|
315
|
+
*
|
|
316
|
+
* @method
|
|
317
|
+
* @param {URL} currentUrl
|
|
318
|
+
* @param {Object} oidc
|
|
319
|
+
* @param {openidClient.Configuration} clientConfig
|
|
320
|
+
* @returns {Promise<User>}
|
|
321
|
+
* @private
|
|
322
|
+
*/
|
|
323
|
+
#authorizeOpenID( currentUrl, oidc, clientConfig ) {
|
|
324
|
+
return new Promise( ( resolve, reject ) => {
|
|
325
|
+
openidClient.authorizationCodeGrant( clientConfig, currentUrl, {
|
|
326
|
+
pkceCodeVerifier: oidc.codeVerifier,
|
|
327
|
+
expectedState: oidc.state,
|
|
328
|
+
expectedNonce: oidc.nonce
|
|
329
|
+
} ).then( ( token ) => {
|
|
330
|
+
const claims = token.claims();
|
|
331
|
+
return openidClient.fetchUserInfo( clientConfig, token.access_token, claims.sub );
|
|
332
|
+
} ).then( ( userInfo ) => {
|
|
333
|
+
const username = userInfo.preferred_username ?? userInfo.email ?? userInfo.name ?? `sub:${ userInfo.sub }`;
|
|
334
|
+
resolve( new User( { userID: `oauth2:${ userInfo.sub }`, username: username, email: userInfo.email, name: userInfo.name } ) );
|
|
335
|
+
} ).catch( ( error ) => {
|
|
336
|
+
reject( exceptions.raise( error ) );
|
|
337
|
+
} );
|
|
338
|
+
} );
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
module.exports = AuthManager;
|
|
344
|
+
module.exports.authMethod = authMethodEnum;
|
|
@@ -0,0 +1,135 @@
|
|
|
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-2026 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
|
+
/**
|
|
10
|
+
* Framework-level authorization helpers. Provides the privileged `admin` role used to gate configuration-editing
|
|
11
|
+
* (and other administrative) routes, plus Express guards. The `admin` role is sourced from a deployment allowlist
|
|
12
|
+
* (`auth.admins` in the web-server config) and applied to the session *after* the application's `augmentSession`
|
|
13
|
+
* hook runs, so it is additive and cannot be clobbered by an app's own (domain) role assignment.
|
|
14
|
+
*
|
|
15
|
+
* @module authorization
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const exceptions = require( "@ti-engine/core/exceptions" );
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The privileged role required to administer configuration. A string value so it never collides with an
|
|
22
|
+
* application's own role codes (e.g. competence uses numeric role codes).
|
|
23
|
+
*
|
|
24
|
+
* @type {string}
|
|
25
|
+
*/
|
|
26
|
+
const ADMIN_ROLE = "admin";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {*} value
|
|
30
|
+
* @returns {string} Trimmed, lower-cased string form (so email/username matching is case-insensitive).
|
|
31
|
+
*/
|
|
32
|
+
function normalizeIdentity( value ) {
|
|
33
|
+
return String( value == null ? "" : value ).trim().toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns `true` if the user matches any entry in the admin allowlist. An entry may match the user's `userID`,
|
|
38
|
+
* `username`, or `email` (case-insensitive).
|
|
39
|
+
*
|
|
40
|
+
* @param {Object} user A session user (`{ userID, username, email, roles, ... }`).
|
|
41
|
+
* @param {string[]} admins The configured allowlist of admin identifiers.
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
function isAdminIdentity( user, admins ) {
|
|
45
|
+
if ( !user || !Array.isArray( admins ) || admins.length === 0 ) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const candidates = new Set( [ user.userID, user.username, user.email ].map( normalizeIdentity ).filter( ( value ) => value.length > 0 ) );
|
|
49
|
+
return admins.some( ( entry ) => candidates.has( normalizeIdentity( entry ) ) );
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Adds the `admin` role to the session user (additively, no duplicates) when the user is in the allowlist.
|
|
54
|
+
* Safe to call with an empty/missing allowlist or session — it is then a no-op. Returns the session for chaining.
|
|
55
|
+
*
|
|
56
|
+
* @param {Object} session
|
|
57
|
+
* @param {string[]} [admins]
|
|
58
|
+
* @returns {Object} The (possibly modified) session.
|
|
59
|
+
*/
|
|
60
|
+
function applyAdminRole( session, admins ) {
|
|
61
|
+
if ( session && session.user && isAdminIdentity( session.user, admins ) ) {
|
|
62
|
+
const roles = Array.isArray( session.user.roles ) ? session.user.roles.slice() : [];
|
|
63
|
+
if ( !roles.includes( ADMIN_ROLE ) ) {
|
|
64
|
+
roles.push( ADMIN_ROLE );
|
|
65
|
+
}
|
|
66
|
+
session.user.roles = roles;
|
|
67
|
+
}
|
|
68
|
+
return session;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {Object} session
|
|
73
|
+
* @param {Array<string|number>} roles
|
|
74
|
+
* @returns {boolean} `true` if the session user holds any of the given roles.
|
|
75
|
+
*/
|
|
76
|
+
function hasAnyRole( session, roles ) {
|
|
77
|
+
const userRoles = ( session && session.user && Array.isArray( session.user.roles ) ) ? session.user.roles : [];
|
|
78
|
+
return roles.some( ( role ) => userRoles.includes( role ) );
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Pure access decision for a resource (e.g. an HTML fragment) that declares a set of required roles. A resource with
|
|
83
|
+
* no required roles (`null` / `undefined` / empty) is public — any (authenticated) user may access it; otherwise the
|
|
84
|
+
* user must hold at least one of the required roles. Roles are treated opaquely, so this works equally for numeric
|
|
85
|
+
* application role codes and the string `admin` role — there is no implicit hierarchy (an `admin`-gated resource is
|
|
86
|
+
* reachable only by holders of the `admin` role, never by a high numeric role). Backs {@link TiWebAppManager#verifyAccess}.
|
|
87
|
+
*
|
|
88
|
+
* @param {Array<string|number>} [requiredRoles] The roles permitted to access the resource; empty/absent = public.
|
|
89
|
+
* @param {Array<string|number>} [userRoles] The roles held by the current session user.
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
function isAccessAllowed( requiredRoles, userRoles ) {
|
|
93
|
+
if ( requiredRoles === null || requiredRoles === undefined ) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if ( !Array.isArray( requiredRoles ) ) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if ( requiredRoles.length === 0 ) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
const roles = Array.isArray( userRoles ) ? userRoles : [];
|
|
103
|
+
return requiredRoles.some( ( role ) => roles.includes( role ) );
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Express middleware factory that admits a request only if its session user holds at least one of the given roles.
|
|
108
|
+
* Responds `401` when unauthenticated (no session user) and `403` when authenticated but lacking the role.
|
|
109
|
+
*
|
|
110
|
+
* @param {...(string|number)} roles
|
|
111
|
+
* @returns {function(Object, Object, Function): void}
|
|
112
|
+
*/
|
|
113
|
+
function requireRole( ...roles ) {
|
|
114
|
+
return ( request, response, next ) => {
|
|
115
|
+
const user = request && request.session && request.session.user;
|
|
116
|
+
if ( !user ) {
|
|
117
|
+
response.status( exceptions.httpCode.C_401 ).end();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if ( !hasAnyRole( request.session, roles ) ) {
|
|
121
|
+
response.status( exceptions.httpCode.C_403 ).end();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
next();
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Express middleware that admits only `admin`-role users.
|
|
130
|
+
*
|
|
131
|
+
* @type {function(Object, Object, Function): void}
|
|
132
|
+
*/
|
|
133
|
+
const requireAdmin = requireRole( ADMIN_ROLE );
|
|
134
|
+
|
|
135
|
+
module.exports = { ADMIN_ROLE, isAdminIdentity, applyAdminRole, hasAnyRole, isAccessAllowed, requireRole, requireAdmin };
|
|
@@ -0,0 +1,98 @@
|
|
|
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-2026 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 { EventEmitter } = require( "node:events" );
|
|
10
|
+
const logger = require( "@ti-engine/core/logger" );
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {Object} ConfigChangeEvent
|
|
14
|
+
* @property {string} changeSetID
|
|
15
|
+
* @property {string[]} configKeys The configuration documents affected by the change.
|
|
16
|
+
* @property {string} adminID Who committed the change.
|
|
17
|
+
* @property {string} timestamp ISO timestamp.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const CONFIG_CHANGED = "config:changed";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Notifies subscribers that configuration changed, so they can react (e.g. invalidate an in-memory cache, or push a
|
|
24
|
+
* live update to an admin UI). This is the **in-process** implementation of a deliberately transport-agnostic
|
|
25
|
+
* contract — `publish(event)` (fire-and-forget) and `subscribe(listener) → unsubscribe`.
|
|
26
|
+
*
|
|
27
|
+
* **Designed for an eventual switch to a reusable core pub/sub.** Cross-instance propagation is out of scope for v1
|
|
28
|
+
* (the store-backed model already makes a committed change visible to every instance via the shared Redis cache;
|
|
29
|
+
* this emitter exists to invalidate optional *in-memory* caches and drive live UI within a process). When a Redis
|
|
30
|
+
* (or other) pub/sub primitive lands in `@ti-engine/core`, a drop-in implementation of this same contract can be
|
|
31
|
+
* provided and injected into {@link ConfigService} — no change to publishers or subscribers. To keep that swap
|
|
32
|
+
* behavior-safe, **delivery here is already asynchronous** (matching cross-instance transports); subscribers must
|
|
33
|
+
* not assume synchronous delivery, and the event payload is plain serializable JSON so it survives a wire transport.
|
|
34
|
+
*
|
|
35
|
+
* @class ConfigChangeNotifier
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
class ConfigChangeNotifier {
|
|
39
|
+
|
|
40
|
+
#emitter = new EventEmitter();
|
|
41
|
+
|
|
42
|
+
constructor() {
|
|
43
|
+
this.#emitter.setMaxListeners( 0 );
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Publishes a configuration-change event to all subscribers. Fire-and-forget; delivery is asynchronous.
|
|
48
|
+
*
|
|
49
|
+
* @method
|
|
50
|
+
* @param {ConfigChangeEvent} event
|
|
51
|
+
* @returns {ConfigChangeEvent} The (frozen) event that will be delivered.
|
|
52
|
+
* @public
|
|
53
|
+
*/
|
|
54
|
+
publish( event ) {
|
|
55
|
+
const payload = Object.freeze( { ...event } );
|
|
56
|
+
setImmediate( () => {
|
|
57
|
+
for ( const listener of this.#emitter.listeners( CONFIG_CHANGED ) ) {
|
|
58
|
+
try {
|
|
59
|
+
listener( payload );
|
|
60
|
+
} catch ( error ) {
|
|
61
|
+
// A misbehaving subscriber must not break delivery to the others or crash the process.
|
|
62
|
+
logger.log( `Config-change subscriber threw: ${ error && error.message ? error.message : error }`, logger.logSeverity.WARNING );
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} );
|
|
66
|
+
return payload;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Subscribes a listener to configuration-change events.
|
|
71
|
+
*
|
|
72
|
+
* @method
|
|
73
|
+
* @param {function(ConfigChangeEvent): void} listener
|
|
74
|
+
* @returns {function(): void} An unsubscribe function.
|
|
75
|
+
* @public
|
|
76
|
+
*/
|
|
77
|
+
subscribe( listener ) {
|
|
78
|
+
this.#emitter.on( CONFIG_CHANGED, listener );
|
|
79
|
+
return () => {
|
|
80
|
+
this.#emitter.off( CONFIG_CHANGED, listener );
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @method
|
|
86
|
+
* @returns {number} The current number of subscribers.
|
|
87
|
+
* @public
|
|
88
|
+
*/
|
|
89
|
+
subscriberCount() {
|
|
90
|
+
return this.#emitter.listenerCount( CONFIG_CHANGED );
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const instance = new ConfigChangeNotifier();
|
|
96
|
+
module.exports = ConfigChangeNotifier;
|
|
97
|
+
module.exports.instance = instance;
|
|
98
|
+
module.exports.CONFIG_CHANGED = CONFIG_CHANGED;
|