@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 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. -->
@@ -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( /^\/static\/(?:.+\/)*[^/]+\.[^/]+$/i );
541
- this.#unprotectedRoutes.push( /^\/\.well-known\/(?:.+\/)*[^/]+\.[^/]+$/i );
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.13.2",
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"