@ti-engine/web-framework 1.13.2 → 1.15.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.
- package/CHANGELOG.md +27 -0
- package/README.md +10 -0
- package/bin/static/fragments/components/component-sidebar-flyout.html +36 -36
- package/bin/static/fragments/frame-login.html +15 -0
- package/bin/web-app-manager.js +99 -0
- package/bin/web-server.js +40 -4
- package/components/auth-manager.js +53 -0
- package/components/web-config-env.js +60 -0
- package/components/web-handlers.js +24 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
This document will contain the list of changes made to the framework. The format is based on the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
|
|
4
4
|
|
|
5
|
+
## Version 1.15.0
|
|
6
|
+
|
|
7
|
+
A dedicated health endpoint, a `TI_WEB_AUTH_METHODS` env override, and login-page gating for every auth method — completing the container-friendly auth/health story for the competence deployment (CA-90).
|
|
8
|
+
|
|
9
|
+
* feat(web-framework): add an unprotected `GET /health` endpoint (`healthHandler`) that returns `200` with `{ status, broker, uptime }` — a purpose-built liveness/readiness probe for container and orchestrator health checks, so probes no longer have to hit the user-facing login route; `broker` reports the Redis connection state
|
|
10
|
+
* feat(web-framework): add the `TI_WEB_AUTH_METHODS` env override (comma-separated) which REPLACES `auth.enabledMethods` — a clean, 12-factor way to select enabled auth methods per deployment (the config-file merge is by-index and cannot cleanly override an array)
|
|
11
|
+
* feat(web-framework): extend login-page auth gating from the OAuth buttons to every method — the `local` credentials form and the "or continue with" divider are now gated too, and a "no method configured" fallback is shown when nothing is enabled, so an SSO-only deployment presents no dead local form
|
|
12
|
+
* build(release): bump package version from `1.14.1` to `1.15.0`
|
|
13
|
+
|
|
14
|
+
## Version 1.14.1
|
|
15
|
+
|
|
16
|
+
Security hardening for the web-server CodeQL findings raised after the scanner was modernized in CA-90 (CA-91).
|
|
17
|
+
|
|
18
|
+
* fix(web-framework): rewrite the default unprotected static-asset route matchers from the ambiguous `(?:.+/)*` to segment-anchored `(?:[^/]+/)*` — the previous form backtracked exponentially and is evaluated against the raw request path in `isUnprotectedRoute()` before authentication, making it a pre-authentication denial-of-service vector (CodeQL js/redos); the matched language for realistic asset paths is unchanged, and the matchers are now the `RE_STATIC_UNPROTECTED` / `RE_WELL_KNOWN_UNPROTECTED` module constants
|
|
19
|
+
* fix(web-framework): replace the login-fragment section stripper's `open[\s\S]*?close` global-regex removal with a linear `indexOf`-based `stripMarkerSpans()` helper (also used for the per-provider stripper), eliminating the polynomial-time rescan on hostile input (CodeQL js/polynomial-redos)
|
|
20
|
+
* docs(web-framework): document that Helmet's built-in Content-Security-Policy is intentionally disabled because a per-request, nonce-based CSP is enforced by `cspHeaderHandler()` on the following middleware — added an explanatory comment and an inline CodeQL suppression (the alert is a false positive)
|
|
21
|
+
* build(release): bump package version from `1.14.0` to `1.14.1`
|
|
22
|
+
|
|
23
|
+
## Version 1.14.0
|
|
24
|
+
|
|
25
|
+
`TI_WEB_*` environment-variable overrides for the web server configuration, enabling 12-factor container deployments without per-environment config files (CA-90).
|
|
26
|
+
|
|
27
|
+
* feat(web-framework): add `applyWebConfigEnvOverrides( config, env = process.env )` (`#web-config-env`) — a pure helper applying `TI_WEB_HOST`, `TI_WEB_PORT`, `TI_WEB_USE_TLS`, `TI_WEB_TLS_CERT_PATH`, `TI_WEB_TLS_KEY_PATH`, and `TI_WEB_COOKIE_SECRET` overrides onto the merged `TiWebServer` configuration, only when each variable is defined (fully backward compatible)
|
|
28
|
+
* fix(web-framework): skip an enabled OpenID Connect provider that has no client ID instead of crashing the instance during discovery — an OAuth-less deployment (e.g. a container started without OAuth credentials) now boots on its remaining methods, with a warning, and reports the dropped provider as unavailable so a sign-in attempt against it fails per-request rather than at startup
|
|
29
|
+
* feat(web-framework): the login page now renders an OpenID provider button only when that provider is an effective enabled auth method — the web server passes the post-drop enabled methods to the app manager, which strips the Google/Azure button (and the whole "or continue with" section when no provider is available) from `frame-login.html` at render time
|
|
30
|
+
* build(release): bump package version from `1.13.2` to `1.14.0`
|
|
31
|
+
|
|
5
32
|
## Version 1.13.0
|
|
6
33
|
|
|
7
34
|
A reusable role-based screen gate and a per-screen title override (back the competence screen-access work and the evaluation/scores screen split).
|
package/README.md
CHANGED
|
@@ -8,6 +8,16 @@ Flexible framework for the creation of microservices with node.js.
|
|
|
8
8
|
|
|
9
9
|
This is a customizable web framework based on the **ti-engine** framework. Currently under development.
|
|
10
10
|
|
|
11
|
+
## Environment variables
|
|
12
|
+
|
|
13
|
+
The web server configuration (host, port, TLS, cookies, etc.) is normally provided via the service configuration file merged in the `TiWebServer` constructor. The following environment variables can override individual values at runtime — useful for container/12-factor deployments where the same image is configured per environment:
|
|
14
|
+
|
|
15
|
+
* `TI_WEB_HOST` overrides the bind address (e.g. `0.0.0.0` in a container). Defaults to the value in the web server config.
|
|
16
|
+
* `TI_WEB_PORT` overrides the listen port.
|
|
17
|
+
* `TI_WEB_USE_TLS` (`true`/`false`) toggles in-app TLS. Set `false` when a reverse proxy / ingress terminates TLS.
|
|
18
|
+
* `TI_WEB_TLS_CERT_PATH` / `TI_WEB_TLS_KEY_PATH` override the TLS certificate/key paths (only used when TLS is enabled).
|
|
19
|
+
* `TI_WEB_COOKIE_SECRET` sets the session cookie signing secret. Set a stable, private value for durable sessions and multi-replica deployments (otherwise a random per-process value is used).
|
|
20
|
+
|
|
11
21
|
## Configure HTTPS for development
|
|
12
22
|
|
|
13
23
|
Use the `mkcert` tool to create a certificate for development.
|
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
<div x-id="['component-flyout']"
|
|
2
|
-
x-data="tiComponentSidebarFlyout('{ti-config-key}')"
|
|
3
|
-
class="ti-sidebar-flyout-container">
|
|
4
|
-
<!--
|
|
5
|
-
Trigger slot. The placeholder consumer can replace this with any custom button — typically a user avatar
|
|
6
|
-
or app menu trigger. The trigger MUST set x-ref="flyoutButton" and call toggle() to integrate with the
|
|
7
|
-
encapsulated flyout state. If the consumer omits inner content, this default icon button is used.
|
|
8
|
-
-->
|
|
9
|
-
<ti-slot>
|
|
10
|
-
<button x-on:click.stop="toggle()"
|
|
11
|
-
x-on:ti-close-all-flyout="close()"
|
|
12
|
-
x-bind:aria-expanded="isOpen"
|
|
13
|
-
x-bind:aria-controls="$id('component-flyout')"
|
|
14
|
-
x-bind:aria-label="menuTitle"
|
|
15
|
-
x-ref="flyoutButton"
|
|
16
|
-
type="button" class="ti-sidebar-button">
|
|
17
|
-
<span class="ti-icon" x-bind:class="icon" aria-hidden="true"></span>
|
|
18
|
-
</button>
|
|
19
|
-
</ti-slot>
|
|
20
|
-
<div x-on:click.outside="close()"
|
|
21
|
-
x-bind:id="$id('component-flyout')"
|
|
22
|
-
x-show="isOpen"
|
|
23
|
-
x-ref="flyoutPanel"
|
|
24
|
-
role="menu" class="ti-sidebar-flyout">
|
|
25
|
-
<template x-for="(buttonConfig, index) in buttonConfigs">
|
|
26
|
-
<button x-on:click="close()"
|
|
27
|
-
x-bind:hx-get="(!buttonConfig.action.method || buttonConfig.action.method === 'get') ? buttonConfig.action.href : null"
|
|
28
|
-
x-bind:hx-post="(buttonConfig.action.method && buttonConfig.action.method === 'post') ? buttonConfig.action.href : null"
|
|
29
|
-
x-bind:hx-target="buttonConfig.action.target"
|
|
30
|
-
x-bind:hx-swap="buttonConfig.action.swap"
|
|
31
|
-
hx-push-url="true"
|
|
32
|
-
type="button" class="ti-sidebar-flyout-item">
|
|
33
|
-
<span class="ti-icon" x-bind:class="buttonConfig.icon" aria-hidden="true"></span><span x-text="buttonConfig.title"></span>
|
|
34
|
-
</button>
|
|
35
|
-
</template>
|
|
36
|
-
</div>
|
|
1
|
+
<div x-id="['component-flyout']"
|
|
2
|
+
x-data="tiComponentSidebarFlyout('{ti-config-key}')"
|
|
3
|
+
class="ti-sidebar-flyout-container">
|
|
4
|
+
<!--
|
|
5
|
+
Trigger slot. The placeholder consumer can replace this with any custom button — typically a user avatar
|
|
6
|
+
or app menu trigger. The trigger MUST set x-ref="flyoutButton" and call toggle() to integrate with the
|
|
7
|
+
encapsulated flyout state. If the consumer omits inner content, this default icon button is used.
|
|
8
|
+
-->
|
|
9
|
+
<ti-slot>
|
|
10
|
+
<button x-on:click.stop="toggle()"
|
|
11
|
+
x-on:ti-close-all-flyout="close()"
|
|
12
|
+
x-bind:aria-expanded="isOpen"
|
|
13
|
+
x-bind:aria-controls="$id('component-flyout')"
|
|
14
|
+
x-bind:aria-label="menuTitle"
|
|
15
|
+
x-ref="flyoutButton"
|
|
16
|
+
type="button" class="ti-sidebar-button">
|
|
17
|
+
<span class="ti-icon" x-bind:class="icon" aria-hidden="true"></span>
|
|
18
|
+
</button>
|
|
19
|
+
</ti-slot>
|
|
20
|
+
<div x-on:click.outside="close()"
|
|
21
|
+
x-bind:id="$id('component-flyout')"
|
|
22
|
+
x-show="isOpen"
|
|
23
|
+
x-ref="flyoutPanel"
|
|
24
|
+
role="menu" class="ti-sidebar-flyout">
|
|
25
|
+
<template x-for="(buttonConfig, index) in buttonConfigs">
|
|
26
|
+
<button x-on:click="close()"
|
|
27
|
+
x-bind:hx-get="(!buttonConfig.action.method || buttonConfig.action.method === 'get') ? buttonConfig.action.href : null"
|
|
28
|
+
x-bind:hx-post="(buttonConfig.action.method && buttonConfig.action.method === 'post') ? buttonConfig.action.href : null"
|
|
29
|
+
x-bind:hx-target="buttonConfig.action.target"
|
|
30
|
+
x-bind:hx-swap="buttonConfig.action.swap"
|
|
31
|
+
hx-push-url="true"
|
|
32
|
+
type="button" class="ti-sidebar-flyout-item">
|
|
33
|
+
<span class="ti-icon" x-bind:class="buttonConfig.icon" aria-hidden="true"></span><span x-text="buttonConfig.title"></span>
|
|
34
|
+
</button>
|
|
35
|
+
</template>
|
|
36
|
+
</div>
|
|
37
37
|
</div>
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<!-- Error message -->
|
|
15
15
|
<div id="ti-error" class="ti-login-error"></div>
|
|
16
16
|
|
|
17
|
+
<!--ti-auth-method:local-->
|
|
17
18
|
<!-- Local auth form -->
|
|
18
19
|
<form id="ti-login-form" action="/login/local" method="post" class="ti-login-form">
|
|
19
20
|
<input type="hidden" name="_csrf" value='{ti-csrf-placeholder}'>
|
|
@@ -45,16 +46,21 @@
|
|
|
45
46
|
<span x-text-label="interface.default.login.sign-in">Sign In</span>
|
|
46
47
|
</button>
|
|
47
48
|
</form>
|
|
49
|
+
<!--/ti-auth-method-->
|
|
48
50
|
|
|
51
|
+
<!--ti-auth-divider-->
|
|
49
52
|
<!-- Divider -->
|
|
50
53
|
<div class="ti-login-divider">
|
|
51
54
|
<div class="ti-login-divider-line"></div>
|
|
52
55
|
<span class="ti-login-divider-label">or continue with</span>
|
|
53
56
|
<div class="ti-login-divider-line"></div>
|
|
54
57
|
</div>
|
|
58
|
+
<!--/ti-auth-divider-->
|
|
55
59
|
|
|
60
|
+
<!--ti-auth-social-->
|
|
56
61
|
<!-- Social auth -->
|
|
57
62
|
<div class="ti-login-social">
|
|
63
|
+
<!--ti-auth-method:openid-google-->
|
|
58
64
|
<a href="/login/openid-google" class="ti-btn full">
|
|
59
65
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
60
66
|
<path d="M21.6 12.23c0-.68-.06-1.33-.17-1.96H12v3.71h5.39a4.6 4.6 0 0 1-2 3.02v2.51h3.23c1.9-1.74 2.98-4.31 2.98-7.28Z" fill="#4285F4"/>
|
|
@@ -67,13 +73,22 @@
|
|
|
67
73
|
</svg>
|
|
68
74
|
<span x-text-label="interface.default.login.sign-in-google">Sign in with Google</span>
|
|
69
75
|
</a>
|
|
76
|
+
<!--/ti-auth-method-->
|
|
77
|
+
<!--ti-auth-method:openid-azure-->
|
|
70
78
|
<a href="/login/openid-azure" class="ti-btn full">
|
|
71
79
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
72
80
|
<path d="M11.5 2L4 8.5l4 1-4 6.5h8l-2-4 4-1-2.5 8H20L14 2h-2.5Z" fill="#0078D4"/>
|
|
73
81
|
</svg>
|
|
74
82
|
<span x-text-label="interface.default.login.sign-in-azure">Sign in with Azure</span>
|
|
75
83
|
</a>
|
|
84
|
+
<!--/ti-auth-method-->
|
|
76
85
|
</div>
|
|
86
|
+
<!--/ti-auth-social-->
|
|
87
|
+
|
|
88
|
+
<!--ti-auth-none-->
|
|
89
|
+
<!-- Shown only when no authentication method is enabled/configured. -->
|
|
90
|
+
<div class="ti-login-empty">No sign-in method is configured. Please contact your administrator.</div>
|
|
91
|
+
<!--/ti-auth-none-->
|
|
77
92
|
</div>
|
|
78
93
|
|
|
79
94
|
<!-- TEMPORARY: Test user selector for augmentSession() experimentation. Remove once AD-driven identity is wired up. -->
|
package/bin/web-app-manager.js
CHANGED
|
@@ -20,6 +20,88 @@ const RE_CSRF_ATTR = /\{ti-csrf-placeholder}/g;
|
|
|
20
20
|
const RE_HTMX_CONFIG = /\{ti-htmx-config-placeholder}/g;
|
|
21
21
|
const RE_CSP_NONCE = /^[A-Za-z0-9+/=_-]{16,}$/;
|
|
22
22
|
const TI_NESTED_FRAME_PLACEHOLDER = "ti-nested-frame-placeholder";
|
|
23
|
+
const OAUTH_METHODS = [ "openid-google", "openid-azure" ];
|
|
24
|
+
const ALL_METHODS = [ "local", "openid-google", "openid-azure" ];
|
|
25
|
+
const RE_AUTH_MARKERS = /<!--\/?ti-auth-(?:divider|social|none|method(?::[a-z-]+)?)-->/g;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Removes every `<openMarker>…<closeMarker>` span (inclusive) from `html` in a single linear pass. The markers are
|
|
29
|
+
* matched as fixed strings via `indexOf`, so — unlike an `openMarker[\s\S]*?closeMarker` regular expression under a
|
|
30
|
+
* global replace — this cannot exhibit super-linear backtracking on hostile input containing many opening markers
|
|
31
|
+
* (CodeQL js/polynomial-redos). Matches the lazy-regex semantics: each opening marker pairs with the *next* closing
|
|
32
|
+
* marker after it. An opening marker with no matching closing marker is left untouched (the caller strips any stray
|
|
33
|
+
* markers afterwards with {@link RE_AUTH_MARKERS}).
|
|
34
|
+
*
|
|
35
|
+
* @param {string} html
|
|
36
|
+
* @param {string} openMarker
|
|
37
|
+
* @param {string} closeMarker
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function stripMarkerSpans( html, openMarker, closeMarker ) {
|
|
41
|
+
let result = "";
|
|
42
|
+
let cursor = 0;
|
|
43
|
+
for ( ; ; ) {
|
|
44
|
+
const open = html.indexOf( openMarker, cursor );
|
|
45
|
+
if ( open === -1 ) {
|
|
46
|
+
result += html.slice( cursor );
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
const close = html.indexOf( closeMarker, open + openMarker.length );
|
|
50
|
+
if ( close === -1 ) {
|
|
51
|
+
result += html.slice( cursor );
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
result += html.slice( cursor, open );
|
|
55
|
+
cursor = close + closeMarker.length;
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Gates the login-page authentication markup to the effective enabled methods. The login fragment delimits blocks
|
|
62
|
+
* with HTML-comment markers: `<!--ti-auth-method:METHOD-->…<!--/ti-auth-method-->` around each method's control
|
|
63
|
+
* (the `local` credentials form and each OpenID provider button), `<!--ti-auth-divider-->…<!--/ti-auth-divider-->`
|
|
64
|
+
* around the "or continue with" separator, `<!--ti-auth-social-->…<!--/ti-auth-social-->` around the SSO button
|
|
65
|
+
* group, and `<!--ti-auth-none-->…<!--/ti-auth-none-->` around a "no method configured" fallback. It removes the
|
|
66
|
+
* block for any method that is not enabled, drops the social group when no SSO provider is enabled, shows the
|
|
67
|
+
* divider only when a local form AND at least one SSO provider are both present, and shows the fallback only when
|
|
68
|
+
* nothing is enabled. Any remaining markers are stripped so clean HTML ships. Fragments without these markers
|
|
69
|
+
* (every non-login fragment) are returned unchanged.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} html
|
|
72
|
+
* @param {string[]} [enabledMethods] The effective enabled authentication methods.
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function applyAuthMethodVisibility( html, enabledMethods ) {
|
|
76
|
+
let result = String( html );
|
|
77
|
+
const enabled = Array.isArray( enabledMethods ) ? enabledMethods : [];
|
|
78
|
+
const localEnabled = enabled.includes( "local" );
|
|
79
|
+
const anyOAuth = OAUTH_METHODS.some( ( method ) => enabled.includes( method ) );
|
|
80
|
+
|
|
81
|
+
// Drop the block for each authentication method that is not enabled.
|
|
82
|
+
ALL_METHODS.forEach( ( method ) => {
|
|
83
|
+
if ( !enabled.includes( method ) ) {
|
|
84
|
+
result = stripMarkerSpans( result, "<!--ti-auth-method:" + method + "-->", "<!--/ti-auth-method-->" );
|
|
85
|
+
}
|
|
86
|
+
} );
|
|
87
|
+
|
|
88
|
+
// Drop the SSO button group when no OpenID provider is enabled.
|
|
89
|
+
if ( !anyOAuth ) {
|
|
90
|
+
result = stripMarkerSpans( result, "<!--ti-auth-social-->", "<!--/ti-auth-social-->" );
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Show the "or continue with" divider only when BOTH a local form and at least one SSO provider are present.
|
|
94
|
+
if ( !( localEnabled && anyOAuth ) ) {
|
|
95
|
+
result = stripMarkerSpans( result, "<!--ti-auth-divider-->", "<!--/ti-auth-divider-->" );
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Show the "no method configured" fallback only when nothing is enabled.
|
|
99
|
+
if ( localEnabled || anyOAuth ) {
|
|
100
|
+
result = stripMarkerSpans( result, "<!--ti-auth-none-->", "<!--/ti-auth-none-->" );
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return result.replace( RE_AUTH_MARKERS, "" );
|
|
104
|
+
}
|
|
23
105
|
|
|
24
106
|
/**
|
|
25
107
|
* A generic web application manager that handles the rendering and behavior of web application views. It is designed to be extended by specific web application
|
|
@@ -39,6 +121,7 @@ class TiWebAppManager {
|
|
|
39
121
|
#fragments = {};
|
|
40
122
|
#staticFileCache = {};
|
|
41
123
|
#staticFileCacheEnabled;
|
|
124
|
+
#enabledAuthMethods = [];
|
|
42
125
|
|
|
43
126
|
/**
|
|
44
127
|
* @constructor
|
|
@@ -173,6 +256,18 @@ class TiWebAppManager {
|
|
|
173
256
|
this.#staticFileCache = {};
|
|
174
257
|
}
|
|
175
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Sets the effective enabled authentication methods used to gate login-page provider buttons. The web server
|
|
261
|
+
* calls this at startup, after the auth manager has dropped any enabled-but-unconfigured OpenID providers.
|
|
262
|
+
*
|
|
263
|
+
* @method
|
|
264
|
+
* @param {string[]} methods
|
|
265
|
+
* @public
|
|
266
|
+
*/
|
|
267
|
+
setEnabledAuthMethods( methods ) {
|
|
268
|
+
this.#enabledAuthMethods = Array.isArray( methods ) ? [ ...methods ] : [];
|
|
269
|
+
}
|
|
270
|
+
|
|
176
271
|
/**
|
|
177
272
|
* Optional HTML transformation hook.
|
|
178
273
|
* <br/>
|
|
@@ -212,6 +307,9 @@ class TiWebAppManager {
|
|
|
212
307
|
|
|
213
308
|
transformedHtml = transformedHtml.replace( "{ti-title-placeholder}", options.title || "" );
|
|
214
309
|
|
|
310
|
+
// Gate login-page OpenID provider buttons to the effective enabled auth methods (no-op on other fragments).
|
|
311
|
+
transformedHtml = applyAuthMethodVisibility( transformedHtml, this.#enabledAuthMethods );
|
|
312
|
+
|
|
215
313
|
resolve( transformedHtml );
|
|
216
314
|
} );
|
|
217
315
|
}
|
|
@@ -562,3 +660,4 @@ class TiWebAppManager {
|
|
|
562
660
|
}
|
|
563
661
|
|
|
564
662
|
module.exports = TiWebAppManager;
|
|
663
|
+
module.exports.applyAuthMethodVisibility = applyAuthMethodVisibility;
|
package/bin/web-server.js
CHANGED
|
@@ -24,6 +24,7 @@ const authMethod = require( "#auth-manager" ).authMethod;
|
|
|
24
24
|
const authorization = require( "#authorization" );
|
|
25
25
|
const adminConfigHandlers = require( "#admin-config-handlers" );
|
|
26
26
|
const configService = require( "#config-service" );
|
|
27
|
+
const applyWebConfigEnvOverrides = require( "#web-config-env" );
|
|
27
28
|
|
|
28
29
|
/** @typedef {import("node:http").Server} NodeServer */
|
|
29
30
|
|
|
@@ -89,6 +90,25 @@ const configService = require( "#config-service" );
|
|
|
89
90
|
|
|
90
91
|
const webServerConfig = require( "#web-server-config" );
|
|
91
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Default unprotected static-asset route matchers. The path segments are matched with `(?:[^/]+\/)*` rather than
|
|
95
|
+
* `(?:.+\/)*`: the inner `[^/]+` cannot also consume the "/" delimiter, so the pattern is unambiguous and matches
|
|
96
|
+
* in linear time. The previous `.+` form was ambiguous and backtracked exponentially on hostile request paths such
|
|
97
|
+
* as `/static/a/a/…/a/x` (no trailing extension) — and these matchers run against the raw request path in
|
|
98
|
+
* {@link TiWebServer#isUnprotectedRoute} BEFORE authentication, so that was a pre-auth denial-of-service vector
|
|
99
|
+
* (CodeQL js/redos). The matched language for realistic asset paths is unchanged.
|
|
100
|
+
*
|
|
101
|
+
* @type {RegExp}
|
|
102
|
+
*/
|
|
103
|
+
const RE_STATIC_UNPROTECTED = /^\/static\/(?:[^/]+\/)*[^/]+\.[^/]+$/i;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Default unprotected `/.well-known/` route matcher. See {@link RE_STATIC_UNPROTECTED} for the ReDoS rationale.
|
|
107
|
+
*
|
|
108
|
+
* @type {RegExp}
|
|
109
|
+
*/
|
|
110
|
+
const RE_WELL_KNOWN_UNPROTECTED = /^\/\.well-known\/(?:[^/]+\/)*[^/]+\.[^/]+$/i;
|
|
111
|
+
|
|
92
112
|
/**
|
|
93
113
|
* A web server microservice based on the ti-engine.
|
|
94
114
|
* <br/>
|
|
@@ -121,7 +141,7 @@ class TiWebServer extends ServiceConsumer {
|
|
|
121
141
|
* @throws {TiException.E_GEN_JS_INTERNAL_ERROR} If the web application manager cannot be loaded.
|
|
122
142
|
*/
|
|
123
143
|
constructor( serviceDomainName, serviceConfig ) {
|
|
124
|
-
super( serviceDomainName, _.merge( {}, webServerConfig, ( _.isObjectLike( serviceConfig ) ) ? serviceConfig : {} ) );
|
|
144
|
+
super( serviceDomainName, applyWebConfigEnvOverrides( _.merge( {}, webServerConfig, ( _.isObjectLike( serviceConfig ) ) ? serviceConfig : {} ) ) );
|
|
125
145
|
|
|
126
146
|
// Include the current host in the list of allowed hosts:
|
|
127
147
|
this.#allowedHosts.push( this.serviceConfig.host );
|
|
@@ -255,6 +275,12 @@ class TiWebServer extends ServiceConsumer {
|
|
|
255
275
|
|
|
256
276
|
// Set up security and session middlewares first:
|
|
257
277
|
this.#webServer.use( webHandlers.nonceGenerationHandler() );
|
|
278
|
+
// Helmet's built-in Content-Security-Policy is intentionally disabled here because a per-request,
|
|
279
|
+
// nonce-based CSP is enforced on the very next line by webHandlers.cspHeaderHandler() (see
|
|
280
|
+
// components/web-handlers.js) — Helmet's static config cannot express per-response nonces. Every other
|
|
281
|
+
// Helmet header (HSTS, X-Content-Type-Options, X-Frame-Options, …) still applies. This is a deliberate
|
|
282
|
+
// architecture, not missing CSP; do not enable Helmet's static CSP here, as that would drop the nonce.
|
|
283
|
+
// codeql[js/insecure-helmet-configuration]
|
|
258
284
|
this.#webServer.use( helmet( { contentSecurityPolicy: false } ) );
|
|
259
285
|
this.#webServer.use( webHandlers.cspHeaderHandler() );
|
|
260
286
|
this.#webServer.use( express.json( { limit: "1mb" } ) );
|
|
@@ -305,6 +331,11 @@ class TiWebServer extends ServiceConsumer {
|
|
|
305
331
|
|
|
306
332
|
return this.#authManager.initialize();
|
|
307
333
|
} ).then( () => {
|
|
334
|
+
// Hand the web application manager the effective enabled auth methods (after any unconfigured OpenID
|
|
335
|
+
// providers were dropped) so the login page only renders providers a user can actually complete.
|
|
336
|
+
if ( this.#webAppManager && typeof this.#webAppManager.setEnabledAuthMethods === "function" ) {
|
|
337
|
+
this.#webAppManager.setEnabledAuthMethods( this.#authManager.getEnabledMethods() );
|
|
338
|
+
}
|
|
308
339
|
return this.#beginListening( this.#netServer, this.serviceConfig.port, this.serviceConfig.host );
|
|
309
340
|
} ).then( ( server ) => {
|
|
310
341
|
if ( server.listening === true ) {
|
|
@@ -497,6 +528,7 @@ class TiWebServer extends ServiceConsumer {
|
|
|
497
528
|
this.#webServer.get( "/login/:method", webHandlers.authenticationHandler( this ) );
|
|
498
529
|
this.#webServer.post( "/login/:method", webHandlers.authenticationHandler( this ) );
|
|
499
530
|
this.#webServer.post( "/logout", webHandlers.logoutHandler() );
|
|
531
|
+
this.#webServer.get( "/health", webHandlers.healthHandler() );
|
|
500
532
|
this.#webServer.get( "/me", webHandlers.userInformationHandler() );
|
|
501
533
|
if ( this.#authManager.isAuthEnabled( authMethod.OPENID_GOOGLE ) ) {
|
|
502
534
|
this.#webServer.get( this.#authManager.getOAuth2CallbackUrl( authMethod.OPENID_GOOGLE ), webHandlers.authorizedOAuth2CallbackHandler( this, authMethod.OPENID_GOOGLE ) );
|
|
@@ -537,8 +569,9 @@ class TiWebServer extends ServiceConsumer {
|
|
|
537
569
|
this.#unprotectedRoutes.push( "/app/config" );
|
|
538
570
|
this.#unprotectedRoutes.push( /^\/login\/[^/]+$/i );
|
|
539
571
|
this.#unprotectedRoutes.push( "/logout" );
|
|
540
|
-
this.#unprotectedRoutes.push(
|
|
541
|
-
this.#unprotectedRoutes.push(
|
|
572
|
+
this.#unprotectedRoutes.push( "/health" );
|
|
573
|
+
this.#unprotectedRoutes.push( RE_STATIC_UNPROTECTED );
|
|
574
|
+
this.#unprotectedRoutes.push( RE_WELL_KNOWN_UNPROTECTED );
|
|
542
575
|
}
|
|
543
576
|
|
|
544
577
|
/* Private interface */
|
|
@@ -601,4 +634,7 @@ class TiWebServer extends ServiceConsumer {
|
|
|
601
634
|
|
|
602
635
|
}
|
|
603
636
|
|
|
604
|
-
module.exports = TiWebServer;
|
|
637
|
+
module.exports = TiWebServer;
|
|
638
|
+
// Exported for unit testing of the ReDoS-hardened matchers; not part of the customization surface.
|
|
639
|
+
module.exports.RE_STATIC_UNPROTECTED = RE_STATIC_UNPROTECTED;
|
|
640
|
+
module.exports.RE_WELL_KNOWN_UNPROTECTED = RE_WELL_KNOWN_UNPROTECTED;
|
|
@@ -105,6 +105,11 @@ class AuthManager {
|
|
|
105
105
|
* @public
|
|
106
106
|
*/
|
|
107
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
|
+
|
|
108
113
|
let promises = [];
|
|
109
114
|
if ( this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
|
|
110
115
|
promises.push( this.#initializeOpenIDClient( this.#authSettings.oauth2.google ).then( ( configuration ) => {
|
|
@@ -136,6 +141,19 @@ class AuthManager {
|
|
|
136
141
|
return this.#authSettings.enabledMethods.includes( authMethod );
|
|
137
142
|
}
|
|
138
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
|
+
|
|
139
157
|
/**
|
|
140
158
|
* Used to authenticate a user via the specified authentication method.
|
|
141
159
|
*
|
|
@@ -210,6 +228,41 @@ class AuthManager {
|
|
|
210
228
|
|
|
211
229
|
/* Private interface */
|
|
212
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Removes any OpenID Connect provider that is enabled but not configured (missing a client ID) from the set
|
|
233
|
+
* of enabled authentication methods, logging a warning for each. This prevents a startup crash during OpenID
|
|
234
|
+
* discovery when an enabled provider has no credentials (e.g. a container started without OAuth env vars): the
|
|
235
|
+
* instance boots on its remaining methods, and `isAuthEnabled` then correctly reports the dropped provider as
|
|
236
|
+
* unavailable so a sign-in attempt against it is rejected per-request instead of taking down startup.
|
|
237
|
+
*
|
|
238
|
+
* @method
|
|
239
|
+
* @private
|
|
240
|
+
*/
|
|
241
|
+
#dropUnconfiguredOpenIDProviders() {
|
|
242
|
+
const providers = [
|
|
243
|
+
{ method: authMethodEnum.OPENID_GOOGLE, oauth2: this.#authSettings.oauth2.google, label: "Google" },
|
|
244
|
+
{ method: authMethodEnum.OPENID_AZURE, oauth2: this.#authSettings.oauth2.azure, label: "Azure" }
|
|
245
|
+
];
|
|
246
|
+
providers.forEach( ( provider ) => {
|
|
247
|
+
if ( this.isAuthEnabled( provider.method ) && !this.#isOpenIDConfigured( provider.oauth2 ) ) {
|
|
248
|
+
this.#authSettings.enabledMethods = this.#authSettings.enabledMethods.filter( ( method ) => method !== provider.method );
|
|
249
|
+
logger.log( `OpenID Connect (${ provider.label }) is enabled but not configured (missing client ID); skipping this provider.`, logger.logSeverity.WARNING );
|
|
250
|
+
}
|
|
251
|
+
} );
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Checks whether an OpenID Connect provider has the minimum configuration required to initialize (a non-empty client ID).
|
|
256
|
+
*
|
|
257
|
+
* @method
|
|
258
|
+
* @param {SettingsOAuth2Client} [oauth2] The provider's OAuth2 settings.
|
|
259
|
+
* @returns {boolean}
|
|
260
|
+
* @private
|
|
261
|
+
*/
|
|
262
|
+
#isOpenIDConfigured( oauth2 ) {
|
|
263
|
+
return !!( oauth2 && typeof oauth2.clientID === "string" && oauth2.clientID.trim() !== "" );
|
|
264
|
+
}
|
|
265
|
+
|
|
213
266
|
/**
|
|
214
267
|
* Used to initialize the OpenID Connect client for the specified OAuth2 authentication method.
|
|
215
268
|
* <br/>
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
const tools = require( "@ti-engine/core/tools" );
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Applies TI_WEB_* environment-variable overrides onto an (already-merged) web server configuration object.
|
|
15
|
+
* Each override is applied ONLY when its environment variable is defined, so an absent variable leaves the
|
|
16
|
+
* configured/default value untouched (fully backward compatible). This gives ti-engine web servers 12-factor,
|
|
17
|
+
* container-friendly control over network binding, TLS, the session cookie secret, and the enabled authentication
|
|
18
|
+
* methods without editing config files. Note `TI_WEB_AUTH_METHODS` fully REPLACES `auth.enabledMethods` (a clean
|
|
19
|
+
* array replacement) rather than merging — the config-file merge is by-index and cannot cleanly override an array.
|
|
20
|
+
*
|
|
21
|
+
* @method
|
|
22
|
+
* @param {Object} config The web server configuration to augment (mutated in place and returned).
|
|
23
|
+
* @param {Object} [env=process.env] The environment source (injectable for testing).
|
|
24
|
+
* @returns {Object} The same config object, with any present overrides applied.
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
function applyWebConfigEnvOverrides( config, env = process.env ) {
|
|
28
|
+
if ( !config || typeof config !== "object" ) {
|
|
29
|
+
return config;
|
|
30
|
+
}
|
|
31
|
+
if ( env.TI_WEB_HOST !== undefined ) {
|
|
32
|
+
config.host = env.TI_WEB_HOST;
|
|
33
|
+
}
|
|
34
|
+
if ( env.TI_WEB_PORT !== undefined ) {
|
|
35
|
+
const port = Number( env.TI_WEB_PORT );
|
|
36
|
+
if ( Number.isInteger( port ) ) {
|
|
37
|
+
config.port = port;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if ( env.TI_WEB_USE_TLS !== undefined ) {
|
|
41
|
+
config.useTLS = tools.toBool( env.TI_WEB_USE_TLS );
|
|
42
|
+
}
|
|
43
|
+
if ( env.TI_WEB_TLS_CERT_PATH !== undefined ) {
|
|
44
|
+
config.tlsCertPath = env.TI_WEB_TLS_CERT_PATH;
|
|
45
|
+
}
|
|
46
|
+
if ( env.TI_WEB_TLS_KEY_PATH !== undefined ) {
|
|
47
|
+
config.tlsKeyPath = env.TI_WEB_TLS_KEY_PATH;
|
|
48
|
+
}
|
|
49
|
+
if ( env.TI_WEB_COOKIE_SECRET !== undefined ) {
|
|
50
|
+
config.cookies = config.cookies || {};
|
|
51
|
+
config.cookies.secret = env.TI_WEB_COOKIE_SECRET;
|
|
52
|
+
}
|
|
53
|
+
if ( env.TI_WEB_AUTH_METHODS !== undefined ) {
|
|
54
|
+
config.auth = config.auth || {};
|
|
55
|
+
config.auth.enabledMethods = env.TI_WEB_AUTH_METHODS.split( "," ).map( ( method ) => method.trim() ).filter( ( method ) => method.length > 0 );
|
|
56
|
+
}
|
|
57
|
+
return config;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = applyWebConfigEnvOverrides;
|
|
@@ -13,6 +13,7 @@ const { randomBytes, timingSafeEqual } = require( "node:crypto" );
|
|
|
13
13
|
const URL = require( "node:url" ).URL;
|
|
14
14
|
const _ = require( "lodash" );
|
|
15
15
|
const helmet = require( "helmet" );
|
|
16
|
+
const cache = require( "@ti-engine/core/cache" );
|
|
16
17
|
const authMethod = require( "#auth-manager" ).authMethod;
|
|
17
18
|
const authorization = require( "#authorization" );
|
|
18
19
|
|
|
@@ -372,6 +373,29 @@ module.exports.logoutHandler = () => {
|
|
|
372
373
|
};
|
|
373
374
|
};
|
|
374
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Handler for a lightweight, unauthenticated health probe. Responds `200` whenever the web server is serving
|
|
378
|
+
* (a liveness signal for container/orchestrator probes), and reports the message-broker (Redis) connection state
|
|
379
|
+
* in the body so it can double as a readiness signal without hitting a user-facing route like the login page.
|
|
380
|
+
*
|
|
381
|
+
* @method
|
|
382
|
+
* @returns {ExpressHandler}
|
|
383
|
+
* @public
|
|
384
|
+
*/
|
|
385
|
+
module.exports.healthHandler = () => {
|
|
386
|
+
return ( request, response ) => {
|
|
387
|
+
const broker = ( cache.instance && cache.instance.isOperational === true ) ? "connected" : "disconnected";
|
|
388
|
+
response.status( exceptions.httpCode.C_200 ).send( {
|
|
389
|
+
isSuccessful: true,
|
|
390
|
+
data: {
|
|
391
|
+
status: "ok",
|
|
392
|
+
broker: broker,
|
|
393
|
+
uptime: Math.round( process.uptime() )
|
|
394
|
+
}
|
|
395
|
+
} );
|
|
396
|
+
};
|
|
397
|
+
};
|
|
398
|
+
|
|
375
399
|
/**
|
|
376
400
|
* Handler for retrieving authenticated user information.
|
|
377
401
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ti-engine/web-framework",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.0",
|
|
4
4
|
"description": "A web-framework based on the ti-engine. It provides a customizable ready-to-use web-server microservice and a set of tools for creating web applications. NOTICE: This is still a work in progress and the full architecture, design, and functionality are not available!",
|
|
5
5
|
"author": "Boris Kostadinov <kostadinov.boris@gmail.com>",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"#session-store": "./components/session-store.js",
|
|
21
21
|
"#user": "./components/user.js",
|
|
22
22
|
"#web-app-manager": "./bin/web-app-manager.js",
|
|
23
|
+
"#web-config-env": "./components/web-config-env.js",
|
|
23
24
|
"#web-handlers": "./components/web-handlers.js",
|
|
24
25
|
"#web-server": "./bin/web-server.js",
|
|
25
26
|
"#web-server-config": "./bin/web-server.json"
|