@ti-engine/web-framework 1.32.0 → 1.34.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,65 @@
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.34.0
6
+
7
+ * fix(web-app): give the login error element fallback text, so it can never render as an empty box. The message
8
+ has been unreadable since it was added (CA-95): `x-text-label` resolves
9
+ `interface.default.login.error-sign-in-failed` out of **this package's** `web-server-labels.json`, and a consuming
10
+ application configures exactly one labels path — its own — so the key does not resolve there. The directive then
11
+ falls back to the element's own text content, and the element was empty. The result was a red alert box with
12
+ nothing in it: visibly broken, and saying nothing to the person who had just failed to sign in. An application
13
+ that wants it localized adds the key to its own catalogue, which is the same arrangement the Profile and About
14
+ screens already use.
15
+ * fix(web-app): drop the `error` query parameter from the address bar once the login screen has shown it, via the
16
+ new `tiToolbox.clearUrlParam( name )` (`history.replaceState`, so no history entry the visitor never navigated
17
+ to). It is a one-time message, not state: left in place, a refresh replays a sign-in failure that already
18
+ happened, a bookmark captures it, and the login screen cannot be returned to its blank state without editing the
19
+ URL by hand. The parameter itself stays, because a top-level browser navigation — an OAuth callback, or a
20
+ non-HTMX form post — cannot be answered with a body and still leave the visitor on the login page; a redirect is
21
+ the only way, and a redirect carries no payload. HTMX requests were never affected: they already receive the
22
+ error through the `ti:error` `HX-Trigger` and touch no query string.
23
+
24
+ ## Version 1.33.0
25
+
26
+ * fix(web-handlers)!: refuse an OpenID Connect callback that cannot be completed through the normal error path
27
+ instead of answering `response.status(400).end()`. That was a bare status with an empty body: the visitor got a
28
+ blank page, nothing was logged, and the three distinct reasons a callback fails were collapsed into one
29
+ indistinguishable response. Whoever had to work out why had neither a message nor a log line to start from. A
30
+ refusal is now raised with an explicit `401`, so it presents exactly like every other sign-in failure — an
31
+ HTML `GET` lands back on the login page with `?error=<code>`, and an API client gets the standard payload.
32
+ Marked breaking because the status and body of a failed callback change; no caller should have been depending on
33
+ an empty 400, but a caller that was will see a `303` or a `401`.
34
+ * feat(web-handlers): distinguish the three failure reasons in the log, with the fact that identifies each.
35
+ `E_WEB_INVALID_REQUEST_QUERY` when the provider returned no authorization code, naming the provider's own
36
+ `error` (usually `access_denied` — the visitor declined consent). `E_SEC_INVALID_EXPIRED_SESSION` when the
37
+ session carries no OAuth state, **naming the host the callback arrived on and why that matters**: a session
38
+ cookie is host-scoped, so a sign-in begun on one hostname and called back on another arrives with no cookie and
39
+ therefore nothing to verify against. A service reachable under more than one name produces exactly this — a
40
+ platform that assigns both a generated and a deterministic hostname, say — and so does a session that expired
41
+ while the visitor sat on the consent screen. `E_SEC_UNAUTHORIZED_ACCESS` when the state does not match the one
42
+ issued. The distinction is in the log rather than the response, because the visitor has no use for it and an
43
+ attacker probing the endpoint should not be handed it. **Nothing secret is logged** — never the authorization
44
+ code, the state values, the PKCE verifier or the nonce — and a test pins that.
45
+ * fix(web-handlers): escape externally-supplied text before it reaches a log line or an error payload. The
46
+ previous two entries put two caller-controlled values into a `WARNING`: the provider's `error` query parameter
47
+ and the request's host headers. The callback endpoint is unprotected and accepts any query string, and query
48
+ parameters are percent-decoded before the handler sees them, so `%0A` arrives as a real newline. The console
49
+ appender writes one line per entry, which makes that newline the end of the line — everything after it reads as a
50
+ separate, entirely attacker-written entry, a forged `NOTICE - Sign-in succeeded for admin` sitting in the log
51
+ looking exactly like a real one. Every character outside printable ASCII is now rendered as a visible `\uXXXX`
52
+ escape and the value is capped at 100 characters, so a field stays diagnosable without being able to end the line
53
+ or flood the log. Escaping happens where the value enters rather than at each use, so nothing downstream can
54
+ reach the raw form. An allowlist of known OAuth error codes was the alternative and is worse: providers emit
55
+ non-standard codes, and the unfamiliar ones are precisely the ones worth reading. Raised by CodeRabbit on the
56
+ review of this branch.
57
+ * fix(web-handlers)!: refuse a callback whose session holds a PKCE verifier but no expected state, rather than
58
+ skipping the comparison. The guard was `oidc.state && state !== oidc.state`, so an absent expected state
59
+ disabled the check entirely. Nothing in the framework produces such a session — `authenticationHandler` writes
60
+ the verifier and the state together — but an unverifiable callback is not a callback to trust, and a guard that
61
+ silently turns itself off when its own input is missing is the wrong shape. `openid-client` checks
62
+ `expectedState` as well; this keeps the refusal where it can be explained.
63
+
5
64
  ## Version 1.32.0
6
65
 
7
66
  * fix(authorization)!: `applyAdminRole` now reconciles the `admin` role in **both** directions — granted when the
@@ -12,12 +12,17 @@
12
12
  <!-- Login card -->
13
13
  <div class="ti-login-card">
14
14
  <!-- Error message -->
15
+ <!-- The element's own text is the FALLBACK the x-text-label directive falls back to, and it has to be
16
+ here: this key lives in the framework's web-server-labels.json, and a consuming application
17
+ configures exactly one labels path — its own — so the framework's catalogue is not loaded and the
18
+ key does not resolve. An empty element therefore rendered an empty red box: visibly broken, saying
19
+ nothing. An application that wants this localized adds the key to its own catalogue. -->
15
20
  <div id="ti-error"
16
21
  class="ti-login-error"
17
22
  x-data="tiLoginError"
18
23
  x-bind:class="{ visible: hasError }"
19
24
  role="alert"
20
- x-text-label="interface.default.login.error-sign-in-failed"></div>
25
+ x-text-label="interface.default.login.error-sign-in-failed">Sign-in failed. Please try again.</div>
21
26
 
22
27
  <!--ti-auth-method:local-->
23
28
  <!-- Local auth form -->
@@ -204,6 +204,32 @@ const configureToolbox = () => {
204
204
  return new URLSearchParams( window.location.search ).get( name );
205
205
  },
206
206
 
207
+ /**
208
+ * Removes a query parameter from the address bar without reloading or adding a history entry.
209
+ * <br/>
210
+ * For a parameter that carries a one-time message: once it has been read and shown, it has done its job, and
211
+ * leaving it in the URL means a refresh or a bookmark replays a failure that is no longer happening.
212
+ * `replaceState` rather than `pushState`, so the back button is not left stepping through states the visitor
213
+ * never navigated to.
214
+ *
215
+ * @method
216
+ * @param {string} name
217
+ * @public
218
+ */
219
+ clearUrlParam( name ) {
220
+ if ( !window.history || typeof window.history.replaceState !== "function" ) {
221
+ return;
222
+ }
223
+ const url = new URL( window.location.href );
224
+ if ( !url.searchParams.has( name ) ) {
225
+ return;
226
+ }
227
+ url.searchParams.delete( name );
228
+ // Keep a bare "?" off the end when the last parameter goes.
229
+ const search = url.searchParams.toString();
230
+ window.history.replaceState( null, "", url.pathname + ( search ? "?" + search : "" ) + url.hash );
231
+ },
232
+
207
233
  /**
208
234
  * Used to get the visible box of the document.
209
235
  *
@@ -1684,6 +1710,10 @@ const configureLoginError = () => {
1684
1710
  init() {
1685
1711
  const tiToolbox = Alpine.store( "tiToolbox" );
1686
1712
  this.hasError = Boolean( tiToolbox.getUrlParam( "error" ) );
1713
+ // The parameter is a one-time message, not state. Read it, show it, drop it — otherwise a refresh (or a
1714
+ // bookmark, or a shared link) replays a sign-in failure that already happened, and the login screen can
1715
+ // never be returned to its blank state without editing the URL by hand.
1716
+ tiToolbox.clearUrlParam( "error" );
1687
1717
  }
1688
1718
  };
1689
1719
  };
@@ -233,7 +233,8 @@ function verifyPassword( password, encoded ) {
233
233
  // Usernames that JavaScript's object model treats specially, rejected here because the storage layer this
234
234
  // module writes through cannot represent all of them safely — reusing the exact trio ('__proto__',
235
235
  // 'constructor', 'prototype') this codebase already treats as reserved at every other prototype-pollution
236
- // boundary (see the CA-91 employee field-path guards in packages/competence). Verified empirically per name,
236
+ // boundary (see the CA-91 employee field-path guards in the competence application, now its own repository).
237
+ // Verified empirically per name,
237
238
  // not assumed uniformly:
238
239
  // - '__proto__' is the one that corrupted storage, in @ti-engine/core **before 1.11.0**.
239
240
  // `cache.instance.setJSON` serializes through `tools.stringifyJSON` —
@@ -94,6 +94,39 @@ const resolveHttpCode = ( exception ) => {
94
94
  return exceptions.httpCode.C_500; // general, communication, or unknown → internal server error
95
95
  };
96
96
 
97
+ /**
98
+ * Longest run of externally-supplied text to put in one log line. Long enough that a real OAuth error code or
99
+ * hostname survives whole, short enough that nobody can flood the log through one field.
100
+ *
101
+ * @type {number}
102
+ */
103
+ const LOG_VALUE_MAX_LENGTH = 100;
104
+
105
+ /**
106
+ * Renders a value that came from outside safe to interpolate into a log message or echo in an error payload.
107
+ * <br/>
108
+ * The console appender writes one line per entry, so a newline inside an interpolated value ends that line and
109
+ * everything after it reads as a separate, entirely attacker-written entry — a forged `NOTICE - sign-in succeeded`,
110
+ * say, sitting in the log looking exactly like a real one. Query parameters are percent-decoded before they reach
111
+ * us, so `%0A` arrives as a genuine line break.
112
+ * <br/>
113
+ * Every character outside printable ASCII becomes a visible `\uXXXX` escape rather than being dropped, so the value
114
+ * stays diagnosable — the point of logging it at all — while losing the ability to end the line. An allowlist of
115
+ * known OAuth error codes was the other option and is worse: providers emit non-standard codes, and the unfamiliar
116
+ * ones are precisely the ones worth reading.
117
+ *
118
+ * @method
119
+ * @param {*} value
120
+ * @returns {string}
121
+ * @private
122
+ */
123
+ const sanitizeExternalValue = ( value ) => {
124
+ const escaped = String( value ?? "" ).replace( /[^\x20-\x7E]/g, ( character ) => {
125
+ return "\\u" + character.charCodeAt( 0 ).toString( 16 ).padStart( 4, "0" );
126
+ } );
127
+ return ( escaped.length > LOG_VALUE_MAX_LENGTH ) ? escaped.slice( 0, LOG_VALUE_MAX_LENGTH ) + "..." : escaped;
128
+ };
129
+
97
130
  /**
98
131
  * Used to assemble the current URL of a request.
99
132
  *
@@ -356,7 +389,18 @@ module.exports.authenticationHandler = ( instance ) => {
356
389
  };
357
390
 
358
391
  /**
359
- * Used to handle the callback from the Google OpenID authentication.
392
+ * Used to handle the callback from an OpenID Connect provider.
393
+ * <br/>
394
+ * A callback that cannot be completed is refused through the normal error path — `next( … )` with an explicit
395
+ * `401` — so it presents exactly like every other sign-in failure: an HTML `GET` lands back on the login page with
396
+ * `?error=<code>`, and an API client gets the standard payload. It used to answer `response.status(400).end()`: a
397
+ * bare status with an empty body, no log line, and the three distinct reasons below collapsed into one blank page
398
+ * that said nothing to the person seeing it and nothing to whoever had to work out why.
399
+ * <br/>
400
+ * The three reasons are distinguished in the log rather than in the response, because the visitor has no use for
401
+ * the difference and an attacker probing the endpoint should not be handed it. Nothing secret is logged — never
402
+ * the authorization code, the state values, the PKCE verifier or the nonce — only the facts that identify which
403
+ * case this is.
360
404
  *
361
405
  * @method
362
406
  * @param {TiWebServer} instance
@@ -368,27 +412,54 @@ module.exports.authorizedOAuth2CallbackHandler = ( instance, authMethod ) => {
368
412
  return ( request, response, next ) => {
369
413
  const code = request.query.code;
370
414
  const state = request.query.state;
371
- const oidc = request.session.oidc || {};
372
- if ( !code || !oidc?.codeVerifier ) {
373
- response.status( exceptions.httpCode.C_400 ).end();
374
- } else if ( oidc.state && state !== oidc.state ) {
375
- response.status( exceptions.httpCode.C_400 ).end();
376
- } else {
377
- instance.authorize( authMethod, new URL( request.originalUrl, getBaseUrl( request ) ), oidc ).then( ( user ) => {
378
- return regenerateAndSaveSession( request, "/", ( session ) => {
379
- session.user = user.asJSON();
380
- session.language = user.language || instance.serviceConfig.language;
415
+ const oidc = request.session?.oidc || {};
416
+
417
+ // The provider did not return an authorization code. Usually the visitor declined consent, or the provider
418
+ // refused the request outright; either way it says so in `error`, which is the single most useful thing to
419
+ // record and is not sensitive.
420
+ if ( !code ) {
421
+ const providerError = sanitizeExternalValue( request.query.error || "none" );
422
+ logger.log( `Refusing an OpenID sign-in via '${ authMethod }': the provider returned no authorization code (error='${ providerError }').`, logger.logSeverity.WARNING );
423
+ next( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_QUERY, { detail: "The identity provider returned no authorization code.", providerError: providerError }, exceptions.httpCode.C_401 ) );
424
+ return;
425
+ }
381
426
 
382
- delete session.oidc;
427
+ // The session holds nothing to verify the callback against. This is the case that used to be hardest to
428
+ // diagnose, so the log names the likely cause: a session cookie is scoped to one host, so a sign-in begun
429
+ // on one hostname and called back on another arrives with no cookie and therefore no state. A service
430
+ // reachable under more than one name — a platform that assigns both a generated and a deterministic
431
+ // hostname, say — produces exactly this, and so does a session that expired while the visitor sat on the
432
+ // provider's consent screen.
433
+ if ( !oidc.codeVerifier ) {
434
+ logger.log( `Refusing an OpenID sign-in via '${ authMethod }': the session carries no OAuth state. The callback arrived on host '${ sanitizeExternalValue( request.get( "x-forwarded-host" ) || request.get( "host" ) || "unknown" ) }' — a session cookie is host-scoped, so check that the sign-in began on this same host and that the session had not expired.`, logger.logSeverity.WARNING );
435
+ next( exceptions.raise( exceptions.exceptionCode.E_SEC_INVALID_EXPIRED_SESSION, { detail: "The session carries no OAuth state for this callback." }, exceptions.httpCode.C_401 ) );
436
+ return;
437
+ }
383
438
 
384
- return authorization.applyAdminRole( instance.augmentSession( session, request ), instance.serviceConfig?.auth?.admins );
385
- } );
386
- } ).then( ( redirectTo ) => {
387
- response.redirect( exceptions.httpCode.C_303, convertUriToString( redirectTo ) );
388
- } ).catch( ( error ) => {
389
- next( exceptions.raise( error, null, exceptions.httpCode.C_401 ) );
390
- } );
439
+ // The state does not match the one issued for this session. A missing expected state is refused too rather
440
+ // than skipped: a session holding a verifier but no state is inconsistent, and an unverifiable callback is
441
+ // not a callback to trust. (`openid-client` checks `expectedState` as well; this keeps the refusal here,
442
+ // where it can be explained.)
443
+ if ( !oidc.state || state !== oidc.state ) {
444
+ logger.log( `Refusing an OpenID sign-in via '${ authMethod }': the callback's state does not match the one issued for this session.`, logger.logSeverity.WARNING );
445
+ next( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, { detail: "The callback state does not match the one issued for this session." }, exceptions.httpCode.C_401 ) );
446
+ return;
391
447
  }
448
+
449
+ instance.authorize( authMethod, new URL( request.originalUrl, getBaseUrl( request ) ), oidc ).then( ( user ) => {
450
+ return regenerateAndSaveSession( request, "/", ( session ) => {
451
+ session.user = user.asJSON();
452
+ session.language = user.language || instance.serviceConfig.language;
453
+
454
+ delete session.oidc;
455
+
456
+ return authorization.applyAdminRole( instance.augmentSession( session, request ), instance.serviceConfig?.auth?.admins );
457
+ } );
458
+ } ).then( ( redirectTo ) => {
459
+ response.redirect( exceptions.httpCode.C_303, convertUriToString( redirectTo ) );
460
+ } ).catch( ( error ) => {
461
+ next( exceptions.raise( error, null, exceptions.httpCode.C_401 ) );
462
+ } );
392
463
  };
393
464
  };
394
465
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ti-engine/web-framework",
3
- "version": "1.32.0",
3
+ "version": "1.34.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
  "keywords": [
6
6
  "ti-engine",