@ti-engine/web-framework 1.28.0 → 1.33.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,99 @@
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.33.0
6
+
7
+ * fix(web-handlers)!: refuse an OpenID Connect callback that cannot be completed through the normal error path
8
+ instead of answering `response.status(400).end()`. That was a bare status with an empty body: the visitor got a
9
+ blank page, nothing was logged, and the three distinct reasons a callback fails were collapsed into one
10
+ indistinguishable response. Whoever had to work out why had neither a message nor a log line to start from. A
11
+ refusal is now raised with an explicit `401`, so it presents exactly like every other sign-in failure — an
12
+ HTML `GET` lands back on the login page with `?error=<code>`, and an API client gets the standard payload.
13
+ Marked breaking because the status and body of a failed callback change; no caller should have been depending on
14
+ an empty 400, but a caller that was will see a `303` or a `401`.
15
+ * feat(web-handlers): distinguish the three failure reasons in the log, with the fact that identifies each.
16
+ `E_WEB_INVALID_REQUEST_QUERY` when the provider returned no authorization code, naming the provider's own
17
+ `error` (usually `access_denied` — the visitor declined consent). `E_SEC_INVALID_EXPIRED_SESSION` when the
18
+ session carries no OAuth state, **naming the host the callback arrived on and why that matters**: a session
19
+ cookie is host-scoped, so a sign-in begun on one hostname and called back on another arrives with no cookie and
20
+ therefore nothing to verify against. A service reachable under more than one name produces exactly this — a
21
+ platform that assigns both a generated and a deterministic hostname, say — and so does a session that expired
22
+ while the visitor sat on the consent screen. `E_SEC_UNAUTHORIZED_ACCESS` when the state does not match the one
23
+ issued. The distinction is in the log rather than the response, because the visitor has no use for it and an
24
+ attacker probing the endpoint should not be handed it. **Nothing secret is logged** — never the authorization
25
+ code, the state values, the PKCE verifier or the nonce — and a test pins that.
26
+ * fix(web-handlers): escape externally-supplied text before it reaches a log line or an error payload. The
27
+ previous two entries put two caller-controlled values into a `WARNING`: the provider's `error` query parameter
28
+ and the request's host headers. The callback endpoint is unprotected and accepts any query string, and query
29
+ parameters are percent-decoded before the handler sees them, so `%0A` arrives as a real newline. The console
30
+ appender writes one line per entry, which makes that newline the end of the line — everything after it reads as a
31
+ separate, entirely attacker-written entry, a forged `NOTICE - Sign-in succeeded for admin` sitting in the log
32
+ looking exactly like a real one. Every character outside printable ASCII is now rendered as a visible `\uXXXX`
33
+ escape and the value is capped at 100 characters, so a field stays diagnosable without being able to end the line
34
+ or flood the log. Escaping happens where the value enters rather than at each use, so nothing downstream can
35
+ reach the raw form. An allowlist of known OAuth error codes was the alternative and is worse: providers emit
36
+ non-standard codes, and the unfamiliar ones are precisely the ones worth reading. Raised by CodeRabbit on the
37
+ review of this branch.
38
+ * fix(web-handlers)!: refuse a callback whose session holds a PKCE verifier but no expected state, rather than
39
+ skipping the comparison. The guard was `oidc.state && state !== oidc.state`, so an absent expected state
40
+ disabled the check entirely. Nothing in the framework produces such a session — `authenticationHandler` writes
41
+ the verifier and the state together — but an unverifiable callback is not a callback to trust, and a guard that
42
+ silently turns itself off when its own input is missing is the wrong shape. `openid-client` checks
43
+ `expectedState` as well; this keeps the refusal where it can be explained.
44
+
45
+ ## Version 1.32.0
46
+
47
+ * fix(authorization)!: `applyAdminRole` now reconciles the `admin` role in **both** directions — granted when the
48
+ identity is on the allowlist, removed when it is not. It only ever added, and it is the only place the role is
49
+ granted, so it was also the only place it could be taken away: an identity removed from `auth.admins` kept `admin`
50
+ for the life of its session, reaching `/admin/config/*` and every admin-gated screen, and since 1.26.0's `rolling`
51
+ cookie that session need never end. Now that 1.29.0 re-applies the role on every request, removal takes effect on
52
+ the next one. Marked breaking because a session that previously retained the role loses it; an empty or absent
53
+ allowlist now means nobody is an administrator rather than that everybody keeps what they had. Raised by CodeRabbit
54
+ on the review of this branch, fixed here rather than in the consumer because the framework owns the role.
55
+
56
+ ## Version 1.31.0
57
+
58
+ * feat(web-handlers): destroy a session that carries a user and fails `verifySession`, instead of only redirecting
59
+ it. `verifySession` had been a seam with a `TODO: Implement this!` and no consumer — the default returns true for
60
+ any session carrying a user, so the "carries a user but fails verification" branch was unreachable. An application
61
+ that overrides it needs the refusal to stick: leaving the session alive means re-deciding the same verdict on every
62
+ request while the shell, which reads `auth.isAuthenticated`, goes on believing the visitor is signed in, and the
63
+ redirect to `/` lands back on the application rather than on a login. The refusal shape is unchanged (`HX-Redirect`
64
+ for HTMX, `303` to `/` for HTML, `401` otherwise) and is served even if the destroy itself fails — a store that
65
+ cannot forget a session is no reason to honour it. **Nothing changes for a consumer using the default
66
+ `verifySession`**, which cannot produce the case; its documentation now describes the override contract rather than
67
+ carrying a TODO.
68
+
69
+ ## Version 1.30.0
70
+
71
+ * feat(auth-manager)!: refuse an OpenID Connect sign-in whose e-mail the provider itself reports as unverified, and
72
+ expose the decision as the pure `AuthManager.isEmailReportedUnverified( userInfo )`. A consumer maps the
73
+ authenticated identity to an application principal by e-mail — competence resolves it against the employee
74
+ directory — so an address the provider has not verified is an unauthenticated claim to be someone, and nothing
75
+ checked it. **Only an explicit `email_verified: false` is a rejection.** An absent claim is not: Google emits the
76
+ claim, the Microsoft identity platform does not emit it at all, and the published competence image defaults to
77
+ Azure, so treating "absent" as "unverified" would refuse every sign-in on the default deployment. Marked breaking
78
+ because a sign-in that previously succeeded can now be refused — but only where the provider was already saying
79
+ the address was unverified. The residual assumption, for a provider that says nothing, is bound out the way
80
+ `INSTALL.md` already prescribes: a tenant-pinned discovery URL, a domain-restricted provider, or matching on the
81
+ stable `sub` rather than the mutable e-mail.
82
+
83
+ ## Version 1.29.0
84
+
85
+ * feat(web-server): add `refreshSession( session, request )` — the per-request companion to `augmentSession` — and
86
+ the `sessionRefreshHandler` middleware that calls it. Roles derived once at sign-in are roles that cannot be taken
87
+ away: `augmentSession` runs inside `regenerateAndSaveSession` and nothing re-ran it, so an authority the
88
+ application withdrew stayed live in every session already holding it, and since 1.26.0's `rolling` cookie an active
89
+ user's session need never expire. The default hook is a no-op, so nothing changes for a consumer that does not
90
+ override it. The middleware is mounted **after** the static handlers and **before** the application routes: an
91
+ asset request carries the same cookie and has no reason to re-derive anything, while every route that can consult
92
+ roles has passed through the hook first. The additive `admin` allowlist role is re-applied immediately afterwards,
93
+ exactly as it is at sign-in, so a hook may replace `session.user.roles` wholesale without stranding an allowlisted
94
+ administrator. Unlike `augmentSession`, throwing does not refuse anything — there is no sign-in to refuse — so a
95
+ failing hook is logged, the session's application roles are dropped, and the request proceeds with the `admin` role
96
+ alone: fail closed on authority, without one failed lookup taking the whole application down.
97
+
5
98
  ## Version 1.28.0
6
99
 
7
100
  * feat(deploy): add `bin/healthcheck.js`, the container liveness probe for any `TiWebServer` application. Point a
package/bin/web-server.js CHANGED
@@ -364,6 +364,11 @@ class TiWebServer extends ServiceConsumer {
364
364
  } ) );
365
365
  } );
366
366
 
367
+ // Re-derive the session's application state before any application route can consult it. Deliberately
368
+ // AFTER the static handlers: an asset request carries the same session cookie, and there is no reason
369
+ // to re-derive a viewer's roles to serve them a stylesheet.
370
+ this.#webServer.use( webHandlers.sessionRefreshHandler( this ) );
371
+
367
372
  // Set up the web application routes:
368
373
  this.defineWebApplicationRoutes();
369
374
 
@@ -438,15 +443,22 @@ class TiWebServer extends ServiceConsumer {
438
443
  }
439
444
 
440
445
  /**
441
- * Used to verify the session of a request.
446
+ * Decides whether a session may continue to hold access. Consulted by `resourceProtectionHandler` on every
447
+ * protected request; an unprotected route short-circuits it, so a static asset never pays for the check.
448
+ * <br/>
449
+ * The default accepts any session carrying a user. **Override it to add an application's own liveness rule** —
450
+ * whether the principal behind the session still exists and is still entitled to one. Returning `false` for a
451
+ * session that carries a user does not merely block the request: the framework destroys that session, so the
452
+ * refusal lands on a real sign-in rather than re-deciding itself on every subsequent request while the shell goes
453
+ * on believing the visitor is signed in. Keep it synchronous and free of I/O — it runs on the request path.
442
454
  *
443
455
  * @method
456
+ * @virtual
444
457
  * @param {TiSession} session
445
458
  * @returns {boolean}
446
459
  * @public
447
460
  */
448
461
  verifySession( session ) {
449
- // TODO: Implement this!
450
462
  return Boolean( session && session.user );
451
463
  }
452
464
 
@@ -471,6 +483,39 @@ class TiWebServer extends ServiceConsumer {
471
483
  return session;
472
484
  }
473
485
 
486
+ /**
487
+ * Hook for the application to re-derive the session's application-owned state on **every** request — the
488
+ * companion to {@link TiWebServer#augmentSession}, which runs only at sign-in. The default is a no-op.
489
+ * <br/>
490
+ * It exists because roles derived once at login are roles that cannot be taken away. `augmentSession` runs inside
491
+ * `regenerateAndSaveSession` and nothing re-runs it, so an authority the application withdraws — a revoked grant,
492
+ * a manager who no longer manages anything — stayed live in every session already holding it. With a `rolling`
493
+ * cookie an active user's session need never expire, so "until they sign out" can mean indefinitely. Deriving
494
+ * per request makes withdrawal take effect on the next click instead.
495
+ * <br/>
496
+ * **Contract.** Runs synchronously on each request that carries a session user, after the static handlers and
497
+ * before any application route, so it must stay cheap and free of I/O — read in-memory state, not a store. The
498
+ * framework re-applies the additive `admin` role immediately afterwards, so an implementation may replace
499
+ * `session.user.roles` wholesale without stranding an allowlisted administrator. Assign only when the value
500
+ * actually changes: express-session persists a session whose serialized form differs, so rewriting an equal array
501
+ * is free but rewriting a *new* value on every request is a store write on every request.
502
+ * <br/>
503
+ * **Failure is fail-closed, not fatal.** Throwing does not refuse the request the way it does at sign-in — there
504
+ * is no sign-in to refuse. The framework logs the failure, strips the session's application roles, and lets the
505
+ * request continue with the `admin` allowlist role alone. A viewer who cannot be authorized keeps no authority,
506
+ * while an administrator retains the access that exists precisely to repair broken application data.
507
+ *
508
+ * @method
509
+ * @virtual
510
+ * @param {TiSession} session
511
+ * @param {Object} [request] Optional Express request object.
512
+ * @returns {TiSession}
513
+ * @public
514
+ */
515
+ refreshSession( session, request ) {
516
+ return session;
517
+ }
518
+
474
519
  /**
475
520
  * Used to authenticate a user via the specified auth method.
476
521
  *
@@ -555,6 +555,29 @@ class AuthManager {
555
555
  } );
556
556
  }
557
557
 
558
+ /**
559
+ * Whether an OpenID Connect `userinfo` response carries an e-mail address the provider itself reports as
560
+ * unverified. Pure, so the decision is testable without a provider.
561
+ * <br/>
562
+ * A consumer maps the authenticated identity to an application principal by e-mail — competence resolves it
563
+ * against the employee directory — so an address the provider has not verified is an unauthenticated claim to be
564
+ * someone, and a sign-in carrying one is refused.
565
+ * <br/>
566
+ * **An ABSENT claim is not a rejection.** Google emits `email_verified`; the Microsoft identity platform does not
567
+ * emit it at all, so treating "absent" as "unverified" would refuse every Azure sign-in — the default method of
568
+ * the published container image. Only an explicit `false` is a rejection. That leaves a residual assumption for a
569
+ * provider that says nothing: bind it out with a tenant-pinned discovery URL (what `INSTALL.md` prescribes for
570
+ * Azure), a domain-restricted provider, or by matching on the stable `sub` rather than the mutable e-mail.
571
+ *
572
+ * @method
573
+ * @param {Object} userInfo The provider's `userinfo` response.
574
+ * @returns {boolean}
575
+ * @public
576
+ */
577
+ static isEmailReportedUnverified( userInfo ) {
578
+ return !!userInfo && userInfo.email_verified === false;
579
+ }
580
+
558
581
  /**
559
582
  * Used to perform the actual OpenID Connect authorization.
560
583
  *
@@ -574,6 +597,10 @@ class AuthManager {
574
597
  const claims = token.claims();
575
598
  return openidClient.fetchUserInfo( clientConfig, token.access_token, claims.sub );
576
599
  } ).then( ( userInfo ) => {
600
+ if ( AuthManager.isEmailReportedUnverified( userInfo ) ) {
601
+ logger.log( `Refusing an OpenID sign-in for subject '${ userInfo.sub }': the provider reports its e-mail address as unverified.`, logger.logSeverity.WARNING );
602
+ throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, { details: "The identity provider reports this e-mail address as unverified." }, exceptions.httpCode.C_401 );
603
+ }
577
604
  const username = userInfo.preferred_username ?? userInfo.email ?? userInfo.name ?? `sub:${ userInfo.sub }`;
578
605
  resolve( new User( { userID: `oauth2:${ userInfo.sub }`, username: username, email: userInfo.email, name: userInfo.name } ) );
579
606
  } ).catch( ( error ) => {
@@ -59,21 +59,35 @@ function isAdminIdentity( user, admins ) {
59
59
  }
60
60
 
61
61
  /**
62
- * Adds the `admin` role to the session user (additively, no duplicates) when the user is in the allowlist.
63
- * Safe to call with an empty/missing allowlist or session it is then a no-op. Returns the session for chaining.
62
+ * Reconciles the `admin` role on the session user against the allowlist: granted when the identity is on it, removed
63
+ * when it is not. Authoritative in both directionsthis is the only place the role is ever granted, so it is also
64
+ * the only place it can be taken away. Safe with a missing session; an empty or absent allowlist means nobody is an
65
+ * administrator, which removes the role rather than preserving it. Returns the session for chaining.
64
66
  *
65
67
  * @param {Object} session
66
68
  * @param {string[]} [admins]
67
69
  * @returns {Object} The (possibly modified) session.
68
70
  */
69
71
  function applyAdminRole( session, admins ) {
70
- if ( session && session.user && isAdminIdentity( session.user, admins ) ) {
71
- const roles = Array.isArray( session.user.roles ) ? session.user.roles.slice() : [];
72
- if ( !roles.includes( ADMIN_ROLE ) ) {
73
- roles.push( ADMIN_ROLE );
74
- }
72
+ if ( !session || !session.user ) {
73
+ return session;
74
+ }
75
+
76
+ const roles = Array.isArray( session.user.roles ) ? session.user.roles.slice() : [];
77
+ const holdsRole = roles.includes( ADMIN_ROLE );
78
+ const isAdmin = isAdminIdentity( session.user, admins );
79
+
80
+ // Authoritative in BOTH directions, not additive. This is the only place the `admin` role is ever granted, so it
81
+ // has to be the place it is taken away: while it only added, an identity removed from the allowlist kept the role
82
+ // for the life of its session — and with a rolling cookie that need never end. Now that the framework re-applies
83
+ // this per request (see sessionRefreshHandler), removal takes effect on the next request instead.
84
+ if ( isAdmin && !holdsRole ) {
85
+ roles.push( ADMIN_ROLE );
75
86
  session.user.roles = roles;
87
+ } else if ( !isAdmin && holdsRole ) {
88
+ session.user.roles = roles.filter( ( role ) => role !== ADMIN_ROLE );
76
89
  }
90
+
77
91
  return session;
78
92
  }
79
93
 
@@ -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
  *
@@ -274,9 +307,11 @@ module.exports.onShutDownHandler = ( instance ) => {
274
307
  module.exports.resourceProtectionHandler = ( instance ) => {
275
308
  return ( request, response, next ) => {
276
309
  if ( instance.isUnprotectedRoute( request.url ) || instance.verifySession( request.session ) ) {
277
- next();
278
- } else {
279
- const redirectTo = "/";
310
+ return next();
311
+ }
312
+
313
+ const redirectTo = "/";
314
+ const refuse = () => {
280
315
  if ( isHtmxRequest( request ) ) {
281
316
  response.set( "HX-Redirect", redirectTo );
282
317
  response.status( exceptions.httpCode.C_204 ).end();
@@ -285,7 +320,26 @@ module.exports.resourceProtectionHandler = ( instance ) => {
285
320
  } else {
286
321
  response.status( exceptions.httpCode.C_401 ).end();
287
322
  }
323
+ };
324
+
325
+ // A session that carries a user and STILL fails verification has been judged invalid — the application looked
326
+ // at who it belongs to and said no. Leaving it alive would mean re-deciding the same refusal on every request
327
+ // while the shell, which reads `auth.isAuthenticated`, goes on believing the visitor is signed in. Destroy it,
328
+ // so the redirect lands on a real login instead of a loop. The default `verifySession` cannot produce this
329
+ // case (it returns true whenever a user is present), so nothing changes for a consumer that does not override
330
+ // it; a destroy that fails is logged and the refusal is served regardless.
331
+ if ( request.session && request.session.user && typeof request.session.destroy === "function" ) {
332
+ logger.log( `Ending a session that failed verification for user '${ request.session.user.userID || request.session.user.employeeID }'.`, logger.logSeverity.NOTICE );
333
+ request.session.destroy( ( error ) => {
334
+ if ( error ) {
335
+ logger.log( "Failed to destroy a session that did not pass verification.", logger.logSeverity.WARNING, error );
336
+ }
337
+ refuse();
338
+ } );
339
+ return;
288
340
  }
341
+
342
+ refuse();
289
343
  };
290
344
  };
291
345
 
@@ -335,7 +389,18 @@ module.exports.authenticationHandler = ( instance ) => {
335
389
  };
336
390
 
337
391
  /**
338
- * 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.
339
404
  *
340
405
  * @method
341
406
  * @param {TiWebServer} instance
@@ -347,27 +412,54 @@ module.exports.authorizedOAuth2CallbackHandler = ( instance, authMethod ) => {
347
412
  return ( request, response, next ) => {
348
413
  const code = request.query.code;
349
414
  const state = request.query.state;
350
- const oidc = request.session.oidc || {};
351
- if ( !code || !oidc?.codeVerifier ) {
352
- response.status( exceptions.httpCode.C_400 ).end();
353
- } else if ( oidc.state && state !== oidc.state ) {
354
- response.status( exceptions.httpCode.C_400 ).end();
355
- } else {
356
- instance.authorize( authMethod, new URL( request.originalUrl, getBaseUrl( request ) ), oidc ).then( ( user ) => {
357
- return regenerateAndSaveSession( request, "/", ( session ) => {
358
- session.user = user.asJSON();
359
- 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
+ }
360
426
 
361
- 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
+ }
362
438
 
363
- return authorization.applyAdminRole( instance.augmentSession( session, request ), instance.serviceConfig?.auth?.admins );
364
- } );
365
- } ).then( ( redirectTo ) => {
366
- response.redirect( exceptions.httpCode.C_303, convertUriToString( redirectTo ) );
367
- } ).catch( ( error ) => {
368
- next( exceptions.raise( error, null, exceptions.httpCode.C_401 ) );
369
- } );
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;
370
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
+ } );
371
463
  };
372
464
  };
373
465
 
@@ -702,6 +794,42 @@ module.exports.webAppHandler = ( instance ) => {
702
794
  };
703
795
  };
704
796
 
797
+ /**
798
+ * Re-derives the session's application-owned state on each request by calling {@link TiWebServer#refreshSession},
799
+ * then re-applies the additive `admin` allowlist role — the same order sign-in uses, so a hook that replaces
800
+ * `session.user.roles` cannot strand an allowlisted administrator.
801
+ * <br/>
802
+ * Mounted after the static handlers and before the application routes: a session-less request (a stylesheet, the
803
+ * health probe, the login page) never reaches the hook, and every request that can consult roles has passed through
804
+ * it first.
805
+ * <br/>
806
+ * A throwing hook is fail-closed rather than fatal: the failure is logged, the session's application roles are
807
+ * dropped, and the request proceeds with the `admin` role alone. Taking the whole application down because one
808
+ * authority lookup failed would be worse than serving it without authority — and the administrator's access is the
809
+ * one that exists to repair the data that broke.
810
+ *
811
+ * @method
812
+ * @param {TiWebServer} instance
813
+ * @returns {ExpressHandler}
814
+ * @public
815
+ */
816
+ module.exports.sessionRefreshHandler = ( instance ) => {
817
+ return ( request, response, next ) => {
818
+ const session = request.session;
819
+ if ( !session || !session.user ) {
820
+ return next();
821
+ }
822
+ try {
823
+ instance.refreshSession( session, request );
824
+ } catch ( error ) {
825
+ logger.log( `Failed to refresh the session for user '${ session.user.userID || session.user.employeeID }'; continuing without application roles.`, logger.logSeverity.ERROR, error );
826
+ session.user.roles = [];
827
+ }
828
+ authorization.applyAdminRole( session, instance.serviceConfig?.auth?.admins );
829
+ next();
830
+ };
831
+ };
832
+
705
833
  /**
706
834
  * Validate Origin/Referer for non-GET/HEAD/OPTIONS requests.
707
835
  * Origin must match the current request origin (protocol + host[:port]).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ti-engine/web-framework",
3
- "version": "1.28.0",
3
+ "version": "1.33.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",
@@ -174,9 +174,17 @@ declare class TiWebServer extends ServiceConsumer {
174
174
  */
175
175
  reportHealthy(): void;
176
176
  /**
177
- * Used to verify the session of a request.
177
+ * Decides whether a session may continue to hold access. Consulted by `resourceProtectionHandler` on every
178
+ * protected request; an unprotected route short-circuits it, so a static asset never pays for the check.
179
+ * <br/>
180
+ * The default accepts any session carrying a user. **Override it to add an application's own liveness rule** —
181
+ * whether the principal behind the session still exists and is still entitled to one. Returning `false` for a
182
+ * session that carries a user does not merely block the request: the framework destroys that session, so the
183
+ * refusal lands on a real sign-in rather than re-deciding itself on every subsequent request while the shell goes
184
+ * on believing the visitor is signed in. Keep it synchronous and free of I/O — it runs on the request path.
178
185
  *
179
186
  * @method
187
+ * @virtual
180
188
  * @param {TiSession} session
181
189
  * @returns {boolean}
182
190
  * @public
@@ -200,6 +208,36 @@ declare class TiWebServer extends ServiceConsumer {
200
208
  * @public
201
209
  */
202
210
  augmentSession(session: TiSession, request?: Object): TiSession;
211
+ /**
212
+ * Hook for the application to re-derive the session's application-owned state on **every** request — the
213
+ * companion to {@link TiWebServer#augmentSession}, which runs only at sign-in. The default is a no-op.
214
+ * <br/>
215
+ * It exists because roles derived once at login are roles that cannot be taken away. `augmentSession` runs inside
216
+ * `regenerateAndSaveSession` and nothing re-runs it, so an authority the application withdraws — a revoked grant,
217
+ * a manager who no longer manages anything — stayed live in every session already holding it. With a `rolling`
218
+ * cookie an active user's session need never expire, so "until they sign out" can mean indefinitely. Deriving
219
+ * per request makes withdrawal take effect on the next click instead.
220
+ * <br/>
221
+ * **Contract.** Runs synchronously on each request that carries a session user, after the static handlers and
222
+ * before any application route, so it must stay cheap and free of I/O — read in-memory state, not a store. The
223
+ * framework re-applies the additive `admin` role immediately afterwards, so an implementation may replace
224
+ * `session.user.roles` wholesale without stranding an allowlisted administrator. Assign only when the value
225
+ * actually changes: express-session persists a session whose serialized form differs, so rewriting an equal array
226
+ * is free but rewriting a *new* value on every request is a store write on every request.
227
+ * <br/>
228
+ * **Failure is fail-closed, not fatal.** Throwing does not refuse the request the way it does at sign-in — there
229
+ * is no sign-in to refuse. The framework logs the failure, strips the session's application roles, and lets the
230
+ * request continue with the `admin` allowlist role alone. A viewer who cannot be authorized keeps no authority,
231
+ * while an administrator retains the access that exists precisely to repair broken application data.
232
+ *
233
+ * @method
234
+ * @virtual
235
+ * @param {TiSession} session
236
+ * @param {Object} [request] Optional Express request object.
237
+ * @returns {TiSession}
238
+ * @public
239
+ */
240
+ refreshSession(session: TiSession, request?: Object): TiSession;
203
241
  /**
204
242
  * Used to authenticate a user via the specified auth method.
205
243
  *
@@ -126,6 +126,26 @@ declare class AuthManager {
126
126
  * @public
127
127
  */
128
128
  static toCallbackPath(callbackUrl: string): string | null;
129
+ /**
130
+ * Whether an OpenID Connect `userinfo` response carries an e-mail address the provider itself reports as
131
+ * unverified. Pure, so the decision is testable without a provider.
132
+ * <br/>
133
+ * A consumer maps the authenticated identity to an application principal by e-mail — competence resolves it
134
+ * against the employee directory — so an address the provider has not verified is an unauthenticated claim to be
135
+ * someone, and a sign-in carrying one is refused.
136
+ * <br/>
137
+ * **An ABSENT claim is not a rejection.** Google emits `email_verified`; the Microsoft identity platform does not
138
+ * emit it at all, so treating "absent" as "unverified" would refuse every Azure sign-in — the default method of
139
+ * the published container image. Only an explicit `false` is a rejection. That leaves a residual assumption for a
140
+ * provider that says nothing: bind it out with a tenant-pinned discovery URL (what `INSTALL.md` prescribes for
141
+ * Azure), a domain-restricted provider, or by matching on the stable `sub` rather than the mutable e-mail.
142
+ *
143
+ * @method
144
+ * @param {Object} userInfo The provider's `userinfo` response.
145
+ * @returns {boolean}
146
+ * @public
147
+ */
148
+ static isEmailReportedUnverified(userInfo: Object): boolean;
129
149
  }
130
150
  declare namespace AuthManager {
131
151
  export { authMethodEnum as authMethod };
@@ -18,8 +18,10 @@ export = _exports;
18
18
  */
19
19
  declare function isAdminIdentity(user: Object, admins: string[]): boolean;
20
20
  /**
21
- * Adds the `admin` role to the session user (additively, no duplicates) when the user is in the allowlist.
22
- * Safe to call with an empty/missing allowlist or session it is then a no-op. Returns the session for chaining.
21
+ * Reconciles the `admin` role on the session user against the allowlist: granted when the identity is on it, removed
22
+ * when it is not. Authoritative in both directionsthis is the only place the role is ever granted, so it is also
23
+ * the only place it can be taken away. Safe with a missing session; an empty or absent allowlist means nobody is an
24
+ * administrator, which removes the role rather than preserving it. Returns the session for chaining.
23
25
  *
24
26
  * @param {Object} session
25
27
  * @param {string[]} [admins]
@@ -12,6 +12,7 @@ export declare var defaultErrorHandler: () => ExpressErrorHandler;
12
12
  export declare var nonceGenerationHandler: () => ExpressHandler;
13
13
  export declare var cspHeaderHandler: () => ExpressHandler;
14
14
  export declare var webAppHandler: (instance: TiWebServer) => ExpressHandler;
15
+ export declare var sessionRefreshHandler: (instance: TiWebServer) => ExpressHandler;
15
16
  export declare var originRefererValidationHandler: (instance: any) => ExpressHandler;
16
17
  export declare var csrfInitHandler: (instance: TiWebServer) => ExpressHandler;
17
18
  export declare var csrfProtectionHandler: () => ExpressHandler;