@ti-engine/web-framework 1.14.1 → 1.16.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,22 @@
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.16.0
6
+
7
+ Support explicitly trusted request origins so state-changing requests (e.g. login) work behind proxies that do not present the app's external host — most notably GitHub Codespaces port forwarding (CA-90).
8
+
9
+ * feat(web-framework): add `TI_WEB_TRUSTED_ORIGINS` (comma-separated) / `config.trustedOrigins`. The `originRefererValidationHandler` now accepts a non-GET request whose `Origin`/`Referer` matches the server-reconstructed base URL **or** any configured trusted origin. Previously such a request behind a proxy that rewrote/omitted the forwarded host was rejected with `E_WEB_INVALID_REQUEST_PARAMETERS` (HTTP 403). Backward compatible (empty list = prior behavior); the CSRF double-submit token check is unchanged and still enforced
10
+ * build(release): bump package version from `1.15.0` to `1.16.0`
11
+
12
+ ## Version 1.15.0
13
+
14
+ 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).
15
+
16
+ * 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
17
+ * 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)
18
+ * 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
19
+ * build(release): bump package version from `1.14.1` to `1.15.0`
20
+
5
21
  ## Version 1.14.1
6
22
 
7
23
  Security hardening for the web-server CodeQL findings raised after the scanner was modernized in CA-90 (CA-91).
@@ -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,15 +46,18 @@
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
 
49
- <!--ti-auth-oauth-section-->
51
+ <!--ti-auth-divider-->
50
52
  <!-- Divider -->
51
53
  <div class="ti-login-divider">
52
54
  <div class="ti-login-divider-line"></div>
53
55
  <span class="ti-login-divider-label">or continue with</span>
54
56
  <div class="ti-login-divider-line"></div>
55
57
  </div>
58
+ <!--/ti-auth-divider-->
56
59
 
60
+ <!--ti-auth-social-->
57
61
  <!-- Social auth -->
58
62
  <div class="ti-login-social">
59
63
  <!--ti-auth-method:openid-google-->
@@ -79,7 +83,12 @@
79
83
  </a>
80
84
  <!--/ti-auth-method-->
81
85
  </div>
82
- <!--/ti-auth-oauth-section-->
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-->
83
92
  </div>
84
93
 
85
94
  <!-- TEMPORARY: Test user selector for augmentSession() experimentation. Remove once AD-driven identity is wired up. -->
@@ -21,7 +21,8 @@ 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
23
  const OAUTH_METHODS = [ "openid-google", "openid-azure" ];
24
- const RE_AUTH_MARKERS = /<!--\/?ti-auth-(?:oauth-section|method(?::[a-z-]+)?)-->/g;
24
+ const ALL_METHODS = [ "local", "openid-google", "openid-azure" ];
25
+ const RE_AUTH_MARKERS = /<!--\/?ti-auth-(?:divider|social|none|method(?::[a-z-]+)?)-->/g;
25
26
 
26
27
  /**
27
28
  * Removes every `<openMarker>…<closeMarker>` span (inclusive) from `html` in a single linear pass. The markers are
@@ -57,12 +58,15 @@ function stripMarkerSpans( html, openMarker, closeMarker ) {
57
58
  }
58
59
 
59
60
  /**
60
- * Removes login-page OpenID provider markup for any OAuth method that is not currently enabled, and removes the
61
- * entire "or continue with" section when no OAuth method is enabled at all. The login fragment delimits the
62
- * relevant blocks with HTML-comment markers: `<!--ti-auth-method:METHOD-->…<!--/ti-auth-method-->` around each
63
- * provider button, and `<!--ti-auth-oauth-section-->…<!--/ti-auth-oauth-section-->` around the divider + social
64
- * block. Any remaining markers are stripped so clean HTML ships. Fragments without these markers (every non-login
65
- * fragment) are returned unchanged.
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.
66
70
  *
67
71
  * @param {string} html
68
72
  * @param {string[]} [enabledMethods] The effective enabled authentication methods.
@@ -71,18 +75,31 @@ function stripMarkerSpans( html, openMarker, closeMarker ) {
71
75
  function applyAuthMethodVisibility( html, enabledMethods ) {
72
76
  let result = String( html );
73
77
  const enabled = Array.isArray( enabledMethods ) ? enabledMethods : [];
78
+ const localEnabled = enabled.includes( "local" );
79
+ const anyOAuth = OAUTH_METHODS.some( ( method ) => enabled.includes( method ) );
74
80
 
75
- if ( !OAUTH_METHODS.some( ( method ) => enabled.includes( method ) ) ) {
76
- // No OAuth providers available — drop the whole "or continue with" section, then any stray markers.
77
- return stripMarkerSpans( result, "<!--ti-auth-oauth-section-->", "<!--/ti-auth-oauth-section-->" ).replace( RE_AUTH_MARKERS, "" );
78
- }
79
-
80
- // Drop the button block for each OAuth method that is not enabled, then strip the remaining markers.
81
- OAUTH_METHODS.forEach( ( method ) => {
81
+ // Drop the block for each authentication method that is not enabled.
82
+ ALL_METHODS.forEach( ( method ) => {
82
83
  if ( !enabled.includes( method ) ) {
83
84
  result = stripMarkerSpans( result, "<!--ti-auth-method:" + method + "-->", "<!--/ti-auth-method-->" );
84
85
  }
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
+
86
103
  return result.replace( RE_AUTH_MARKERS, "" );
87
104
  }
88
105
 
package/bin/web-server.js CHANGED
@@ -301,7 +301,7 @@ class TiWebServer extends ServiceConsumer {
301
301
  store: new SessionStore()
302
302
  } ) );
303
303
  this.#webServer.use( webHandlers.csrfInitHandler( this ) );
304
- this.#webServer.use( webHandlers.originRefererValidationHandler() );
304
+ this.#webServer.use( webHandlers.originRefererValidationHandler( this ) );
305
305
  this.#webServer.use( webHandlers.csrfProtectionHandler() );
306
306
 
307
307
  // Set up the web server routes:
@@ -528,6 +528,7 @@ class TiWebServer extends ServiceConsumer {
528
528
  this.#webServer.get( "/login/:method", webHandlers.authenticationHandler( this ) );
529
529
  this.#webServer.post( "/login/:method", webHandlers.authenticationHandler( this ) );
530
530
  this.#webServer.post( "/logout", webHandlers.logoutHandler() );
531
+ this.#webServer.get( "/health", webHandlers.healthHandler() );
531
532
  this.#webServer.get( "/me", webHandlers.userInformationHandler() );
532
533
  if ( this.#authManager.isAuthEnabled( authMethod.OPENID_GOOGLE ) ) {
533
534
  this.#webServer.get( this.#authManager.getOAuth2CallbackUrl( authMethod.OPENID_GOOGLE ), webHandlers.authorizedOAuth2CallbackHandler( this, authMethod.OPENID_GOOGLE ) );
@@ -568,6 +569,7 @@ class TiWebServer extends ServiceConsumer {
568
569
  this.#unprotectedRoutes.push( "/app/config" );
569
570
  this.#unprotectedRoutes.push( /^\/login\/[^/]+$/i );
570
571
  this.#unprotectedRoutes.push( "/logout" );
572
+ this.#unprotectedRoutes.push( "/health" );
571
573
  this.#unprotectedRoutes.push( RE_STATIC_UNPROTECTED );
572
574
  this.#unprotectedRoutes.push( RE_WELL_KNOWN_UNPROTECTED );
573
575
  }
@@ -14,7 +14,10 @@ const tools = require( "@ti-engine/core/tools" );
14
14
  * Applies TI_WEB_* environment-variable overrides onto an (already-merged) web server configuration object.
15
15
  * Each override is applied ONLY when its environment variable is defined, so an absent variable leaves the
16
16
  * configured/default value untouched (fully backward compatible). This gives ti-engine web servers 12-factor,
17
- * container-friendly control over network binding, TLS, and the session cookie secret without editing config files.
17
+ * container-friendly control over network binding, TLS, the session cookie secret, the enabled authentication
18
+ * methods, and the trusted request origins without editing config files. Note `TI_WEB_AUTH_METHODS` and
19
+ * `TI_WEB_TRUSTED_ORIGINS` fully REPLACE their config arrays (`auth.enabledMethods` / `trustedOrigins`) rather than
20
+ * merging — the config-file merge is by-index and cannot cleanly override an array.
18
21
  *
19
22
  * @method
20
23
  * @param {Object} config The web server configuration to augment (mutated in place and returned).
@@ -48,6 +51,13 @@ function applyWebConfigEnvOverrides( config, env = process.env ) {
48
51
  config.cookies = config.cookies || {};
49
52
  config.cookies.secret = env.TI_WEB_COOKIE_SECRET;
50
53
  }
54
+ if ( env.TI_WEB_AUTH_METHODS !== undefined ) {
55
+ config.auth = config.auth || {};
56
+ config.auth.enabledMethods = env.TI_WEB_AUTH_METHODS.split( "," ).map( ( method ) => method.trim() ).filter( ( method ) => method.length > 0 );
57
+ }
58
+ if ( env.TI_WEB_TRUSTED_ORIGINS !== undefined ) {
59
+ config.trustedOrigins = env.TI_WEB_TRUSTED_ORIGINS.split( "," ).map( ( origin ) => origin.trim() ).filter( ( origin ) => origin.length > 0 );
60
+ }
51
61
  return config;
52
62
  }
53
63
 
@@ -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
  *
@@ -666,19 +690,30 @@ module.exports.webAppHandler = ( instance ) => {
666
690
  * @returns {ExpressHandler}
667
691
  * @public
668
692
  */
669
- module.exports.originRefererValidationHandler = () => {
693
+ module.exports.originRefererValidationHandler = ( instance ) => {
670
694
  return ( request, response, next ) => {
671
695
  if ( request.method === "GET" || request.method === "HEAD" || request.method === "OPTIONS" ) {
672
696
  next();
673
697
  } else {
674
- const expectedOrigin = getBaseUrl( request );
675
698
  const providedOrigin = getRequestOrigin( request );
676
- // If the browser didnt send Origin/Referer (normal for same-origin form POSTs), let CSRF middleware handle protection instead of blocking here:
677
- if ( providedOrigin && String( providedOrigin ).trim().toLowerCase() !== String( expectedOrigin ).trim().toLowerCase() ) {
678
- logger.log( `Issue identified with origin/referer mismatch. Expected '${ expectedOrigin }', received '${ providedOrigin }'.`, logger.logSeverity.WARNING );
679
- next( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, null, exceptions.httpCode.C_403 ) );
680
- } else {
699
+ // If the browser didn't send Origin/Referer (normal for same-origin form POSTs), let CSRF middleware handle protection instead of blocking here:
700
+ if ( !providedOrigin ) {
681
701
  next();
702
+ } else {
703
+ // Accept the origin the server reconstructs from the request, plus any explicitly trusted origins
704
+ // (TI_WEB_TRUSTED_ORIGINS / config.trustedOrigins). The trusted list is needed behind proxies that do
705
+ // not present the external host to the app (e.g. GitHub Codespaces port forwarding), where the browser
706
+ // Origin cannot be reconstructed from the forwarded headers.
707
+ const configured = ( instance && instance.serviceConfig && Array.isArray( instance.serviceConfig.trustedOrigins ) ) ? instance.serviceConfig.trustedOrigins : [];
708
+ const allowedOrigins = [ getBaseUrl( request ) ].concat( configured );
709
+ const normalizedProvided = String( providedOrigin ).trim().toLowerCase();
710
+ const isAllowed = allowedOrigins.some( ( origin ) => String( origin ).trim().toLowerCase() === normalizedProvided );
711
+ if ( isAllowed ) {
712
+ next();
713
+ } else {
714
+ logger.log( `Issue identified with origin/referer mismatch. Received '${ providedOrigin }'; expected one of [ ${ allowedOrigins.join( ", " ) } ].`, logger.logSeverity.WARNING );
715
+ next( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, null, exceptions.httpCode.C_403 ) );
716
+ }
682
717
  }
683
718
  }
684
719
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ti-engine/web-framework",
3
- "version": "1.14.1",
3
+ "version": "1.16.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",