@ti-engine/web-framework 1.27.0 → 1.32.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 +69 -0
- package/README.md +22 -0
- package/bin/healthcheck.js +136 -0
- package/bin/web-server.js +47 -2
- package/components/auth-manager.js +27 -0
- package/components/authorization.js +21 -7
- package/components/web-handlers.js +60 -3
- package/package.json +1 -1
- package/types/bin/healthcheck.d.ts +1 -0
- package/types/bin/web-server.d.ts +39 -1
- package/types/components/auth-manager.d.ts +20 -0
- package/types/components/authorization.d.ts +4 -2
- package/types/components/web-handlers.d.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,75 @@
|
|
|
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.32.0
|
|
6
|
+
|
|
7
|
+
* fix(authorization)!: `applyAdminRole` now reconciles the `admin` role in **both** directions — granted when the
|
|
8
|
+
identity is on the allowlist, removed when it is not. It only ever added, and it is the only place the role is
|
|
9
|
+
granted, so it was also the only place it could be taken away: an identity removed from `auth.admins` kept `admin`
|
|
10
|
+
for the life of its session, reaching `/admin/config/*` and every admin-gated screen, and since 1.26.0's `rolling`
|
|
11
|
+
cookie that session need never end. Now that 1.29.0 re-applies the role on every request, removal takes effect on
|
|
12
|
+
the next one. Marked breaking because a session that previously retained the role loses it; an empty or absent
|
|
13
|
+
allowlist now means nobody is an administrator rather than that everybody keeps what they had. Raised by CodeRabbit
|
|
14
|
+
on the review of this branch, fixed here rather than in the consumer because the framework owns the role.
|
|
15
|
+
|
|
16
|
+
## Version 1.31.0
|
|
17
|
+
|
|
18
|
+
* feat(web-handlers): destroy a session that carries a user and fails `verifySession`, instead of only redirecting
|
|
19
|
+
it. `verifySession` had been a seam with a `TODO: Implement this!` and no consumer — the default returns true for
|
|
20
|
+
any session carrying a user, so the "carries a user but fails verification" branch was unreachable. An application
|
|
21
|
+
that overrides it needs the refusal to stick: leaving the session alive means re-deciding the same verdict on every
|
|
22
|
+
request while the shell, which reads `auth.isAuthenticated`, goes on believing the visitor is signed in, and the
|
|
23
|
+
redirect to `/` lands back on the application rather than on a login. The refusal shape is unchanged (`HX-Redirect`
|
|
24
|
+
for HTMX, `303` to `/` for HTML, `401` otherwise) and is served even if the destroy itself fails — a store that
|
|
25
|
+
cannot forget a session is no reason to honour it. **Nothing changes for a consumer using the default
|
|
26
|
+
`verifySession`**, which cannot produce the case; its documentation now describes the override contract rather than
|
|
27
|
+
carrying a TODO.
|
|
28
|
+
|
|
29
|
+
## Version 1.30.0
|
|
30
|
+
|
|
31
|
+
* feat(auth-manager)!: refuse an OpenID Connect sign-in whose e-mail the provider itself reports as unverified, and
|
|
32
|
+
expose the decision as the pure `AuthManager.isEmailReportedUnverified( userInfo )`. A consumer maps the
|
|
33
|
+
authenticated identity to an application principal by e-mail — competence resolves it against the employee
|
|
34
|
+
directory — so an address the provider has not verified is an unauthenticated claim to be someone, and nothing
|
|
35
|
+
checked it. **Only an explicit `email_verified: false` is a rejection.** An absent claim is not: Google emits the
|
|
36
|
+
claim, the Microsoft identity platform does not emit it at all, and the published competence image defaults to
|
|
37
|
+
Azure, so treating "absent" as "unverified" would refuse every sign-in on the default deployment. Marked breaking
|
|
38
|
+
because a sign-in that previously succeeded can now be refused — but only where the provider was already saying
|
|
39
|
+
the address was unverified. The residual assumption, for a provider that says nothing, is bound out the way
|
|
40
|
+
`INSTALL.md` already prescribes: a tenant-pinned discovery URL, a domain-restricted provider, or matching on the
|
|
41
|
+
stable `sub` rather than the mutable e-mail.
|
|
42
|
+
|
|
43
|
+
## Version 1.29.0
|
|
44
|
+
|
|
45
|
+
* feat(web-server): add `refreshSession( session, request )` — the per-request companion to `augmentSession` — and
|
|
46
|
+
the `sessionRefreshHandler` middleware that calls it. Roles derived once at sign-in are roles that cannot be taken
|
|
47
|
+
away: `augmentSession` runs inside `regenerateAndSaveSession` and nothing re-ran it, so an authority the
|
|
48
|
+
application withdrew stayed live in every session already holding it, and since 1.26.0's `rolling` cookie an active
|
|
49
|
+
user's session need never expire. The default hook is a no-op, so nothing changes for a consumer that does not
|
|
50
|
+
override it. The middleware is mounted **after** the static handlers and **before** the application routes: an
|
|
51
|
+
asset request carries the same cookie and has no reason to re-derive anything, while every route that can consult
|
|
52
|
+
roles has passed through the hook first. The additive `admin` allowlist role is re-applied immediately afterwards,
|
|
53
|
+
exactly as it is at sign-in, so a hook may replace `session.user.roles` wholesale without stranding an allowlisted
|
|
54
|
+
administrator. Unlike `augmentSession`, throwing does not refuse anything — there is no sign-in to refuse — so a
|
|
55
|
+
failing hook is logged, the session's application roles are dropped, and the request proceeds with the `admin` role
|
|
56
|
+
alone: fail closed on authority, without one failed lookup taking the whole application down.
|
|
57
|
+
|
|
58
|
+
## Version 1.28.0
|
|
59
|
+
|
|
60
|
+
* feat(deploy): add `bin/healthcheck.js`, the container liveness probe for any `TiWebServer` application. Point a
|
|
61
|
+
Dockerfile `HEALTHCHECK` at `node /app/node_modules/@ti-engine/web-framework/bin/healthcheck.js`. It belongs here
|
|
62
|
+
rather than in each application because every input it reads is the framework's: `TI_WEB_USE_TLS`, `TI_WEB_PORT`
|
|
63
|
+
and `TI_WEB_TLS_CERT_PATH` are the framework's environment overrides, and `/health` is the framework's own route,
|
|
64
|
+
served by `webHandlers.healthHandler`. The framework has always provided the endpoint and never anything to call
|
|
65
|
+
it, which left every consumer writing an inline `node -e` — and the obvious inline version hardcodes `http://`,
|
|
66
|
+
so it reports a TLS-enabled container unhealthy forever and Docker restarts a server that is answering correctly.
|
|
67
|
+
The transport comes from the same `tools.toBool` the server uses, so the two cannot drift. With TLS on the
|
|
68
|
+
certificate is verified rather than skipped: trust is anchored to the server's own certificate and the name to
|
|
69
|
+
check is read out of it, so one issued for a public hostname passes while the probe connects to `127.0.0.1`.
|
|
70
|
+
Without a configured certificate the probe falls back to establishing that the port accepts connections — weaker,
|
|
71
|
+
but it neither disables verification nor restarts a healthy container. Arrived in competence 3.34.0 and moved
|
|
72
|
+
here unchanged in behaviour.
|
|
73
|
+
|
|
5
74
|
## Version 1.27.0
|
|
6
75
|
|
|
7
76
|
Session lifetime. Two defects that together threw a signed-in user out roughly ten minutes after sign-in, however
|
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ The web server configuration (host, port, TLS, cookies, etc.) is normally provid
|
|
|
17
17
|
* `TI_WEB_USE_TLS` (`true`/`false`) toggles in-app TLS. Set `false` when a reverse proxy / ingress terminates TLS.
|
|
18
18
|
* `TI_WEB_TLS_CERT_PATH` / `TI_WEB_TLS_KEY_PATH` override the TLS certificate/key paths (only used when TLS is enabled).
|
|
19
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
|
+
* `TI_WEB_TLS_CERT_PATH` is also read by the container liveness probe (below), which verifies against that certificate.
|
|
20
21
|
* `TI_WEB_SESSION_IDLE_TIMEOUT` (whole minutes) sets how long a signed-in session survives **without activity**, overriding `cookies.maxAge`. The window is rolling: every response re-stamps the cookie, so a session ends only after that long with no request at all. Note that a user typing into a form makes no requests, so set this comfortably longer than the longest form a user fills in one sitting. Defaults to 480 (eight hours).
|
|
21
22
|
* `TI_WEB_AUTH_METHODS` (comma-separated) **replaces** the enabled authentication methods (`auth.enabledMethods`), e.g. `openid-google` or `local,openid-google`.
|
|
22
23
|
* `TI_WEB_AUTH_LOCAL_USERS_PATH` overrides the local user directory's file path (`auth.local.usersPath`), which backs `local` sign-in. An explicitly empty value means *no directory*, so every local sign-in is refused. See [Local (username/password) authentication](#local-usernamepassword-authentication).
|
|
@@ -129,3 +130,24 @@ mkcert localhost 127.0.0.1 ::1
|
|
|
129
130
|
## License
|
|
130
131
|
|
|
131
132
|
Apache-2.0 © Boris Kostadinov. See [LICENSE](LICENSE).
|
|
133
|
+
|
|
134
|
+
## Container liveness probe
|
|
135
|
+
|
|
136
|
+
`bin/healthcheck.js` asks a running server whether it is still serving and exits 0 only if it says yes. Point a
|
|
137
|
+
Dockerfile `HEALTHCHECK` at it:
|
|
138
|
+
|
|
139
|
+
```dockerfile
|
|
140
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
|
141
|
+
CMD ["node", "/app/node_modules/@ti-engine/web-framework/bin/healthcheck.js"]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
It reads `TI_WEB_USE_TLS` through the same `tools.toBool` the server uses, so the probe's transport cannot drift
|
|
145
|
+
from the server's, and calls `/health` — the unprotected route `webHandlers.healthHandler` serves. Writing this
|
|
146
|
+
inline in a Dockerfile is the mistake it exists to prevent: an `http://` URL hardcoded there reports a TLS-enabled
|
|
147
|
+
container unhealthy forever, and Docker restarts a server that is answering correctly.
|
|
148
|
+
|
|
149
|
+
With TLS on it verifies the certificate rather than skipping verification, anchoring trust to the server's own
|
|
150
|
+
certificate at `TI_WEB_TLS_CERT_PATH` and taking the name to check from that certificate — so a certificate issued
|
|
151
|
+
for a public hostname still passes while the probe connects to `127.0.0.1`. Set that variable when you terminate
|
|
152
|
+
TLS inside the container. Without it there is nothing to anchor to and the probe falls back to establishing that
|
|
153
|
+
the port accepts connections, which is weaker but neither disables verification nor restarts a healthy container.
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Container liveness probe for any application built on {@link TiWebServer}: ask the server whether it is still
|
|
20
|
+
* serving, and exit 0 only if it says yes. Invoke it from a Dockerfile `HEALTHCHECK`, the way the competence image
|
|
21
|
+
* does:
|
|
22
|
+
* <br/>
|
|
23
|
+
* `HEALTHCHECK CMD ["node", "/app/node_modules/@ti-engine/web-framework/bin/healthcheck.js"]`
|
|
24
|
+
* <br/>
|
|
25
|
+
* It lives here rather than in an application because every input it reads is the framework's: `TI_WEB_USE_TLS`,
|
|
26
|
+
* `TI_WEB_PORT` and `TI_WEB_TLS_CERT_PATH` are the framework's environment overrides, `/health` is the framework's
|
|
27
|
+
* route, and the endpoint it calls is `webHandlers.healthHandler`. An application copying this file would be
|
|
28
|
+
* copying framework behaviour, and would silently keep the old behaviour when the framework's changed.
|
|
29
|
+
* <br/>
|
|
30
|
+
* The obvious implementation is an inline `node -e` in the Dockerfile, and the obvious mistake is what that
|
|
31
|
+
* encourages: hardcoding `require('http')` and an `http://` URL. That works for as long as TLS is off, and the
|
|
32
|
+
* moment an image is run with TLS on — supported, via a mounted certificate rather than terminating at a proxy —
|
|
33
|
+
* the probe speaks plain HTTP to a TLS listener, fails every time, and Docker reports a healthy container
|
|
34
|
+
* unhealthy, restarting it on a loop.
|
|
35
|
+
* <br/>
|
|
36
|
+
* The transport comes from the same variable and the same parser the server uses, so the two cannot drift:
|
|
37
|
+
* `tools.toBool` treats an unset value and `false`/`0`/`no`/`N` as false, and anything else as true.
|
|
38
|
+
* <br/>
|
|
39
|
+
* <b>On certificates.</b> The obvious way to make a loopback TLS probe work is `rejectUnauthorized: false`, and
|
|
40
|
+
* the obvious defence is that nothing can sit between a process and itself. Both are true and it is still the wrong
|
|
41
|
+
* line to write: it is the snippet that gets copied out of a health probe and into a client that does cross a
|
|
42
|
+
* network. So verification stays on and the trust anchor is narrowed instead — the server's own certificate, named
|
|
43
|
+
* by `TI_WEB_TLS_CERT_PATH`, is passed as the sole CA, and the name to verify is read out of that certificate
|
|
44
|
+
* rather than assumed to be `127.0.0.1` (a certificate issued for a public hostname is the normal case, and a probe
|
|
45
|
+
* that reported "dead" because of the name on it would be repeating the bug this file exists to fix).
|
|
46
|
+
* <br/>
|
|
47
|
+
* When no certificate path is configured there is nothing to anchor to, and the probe degrades to establishing that
|
|
48
|
+
* the port is accepting connections. That is weaker — it shows the listener is up, not that the application is
|
|
49
|
+
* answering — but it is honest, and it is the only remaining option that neither disables verification nor kills a
|
|
50
|
+
* container that is running perfectly well.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
const fs = require( "node:fs" );
|
|
54
|
+
const net = require( "node:net" );
|
|
55
|
+
const tools = require( "@ti-engine/core/tools" );
|
|
56
|
+
|
|
57
|
+
const HOST = "127.0.0.1";
|
|
58
|
+
const TIMEOUT_MS = 4000; // below Docker's own --timeout, so a hung socket fails as unhealthy rather than being killed mid-probe
|
|
59
|
+
const port = process.env.TI_WEB_PORT || 3000;
|
|
60
|
+
const useTLS = tools.toBool( process.env.TI_WEB_USE_TLS );
|
|
61
|
+
|
|
62
|
+
const alive = () => process.exit( 0 );
|
|
63
|
+
const dead = () => process.exit( 1 );
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reads the server's own certificate and works out what to trust and what name to verify against.
|
|
67
|
+
*
|
|
68
|
+
* @returns {{ca: Buffer, servername: (string|undefined)}|null} null when no certificate is configured or readable.
|
|
69
|
+
*/
|
|
70
|
+
function ownCertificate() {
|
|
71
|
+
const certPath = process.env.TI_WEB_TLS_CERT_PATH;
|
|
72
|
+
if ( !certPath ) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const pem = fs.readFileSync( certPath );
|
|
77
|
+
const certificate = new ( require( "node:crypto" ).X509Certificate )( pem );
|
|
78
|
+
// Connecting by IP verifies against an `IP Address:` entry, so a certificate that covers the loopback
|
|
79
|
+
// address needs no SNI at all. Otherwise the first DNS name it carries is the name it can satisfy; failing
|
|
80
|
+
// that, its common name.
|
|
81
|
+
const names = String( certificate.subjectAltName || "" ).split( "," ).map( ( entry ) => entry.trim() );
|
|
82
|
+
if ( names.includes( `IP Address:${ HOST }` ) ) {
|
|
83
|
+
return { ca: pem, servername: undefined };
|
|
84
|
+
}
|
|
85
|
+
const dnsName = names.find( ( entry ) => entry.startsWith( "DNS:" ) );
|
|
86
|
+
if ( dnsName ) {
|
|
87
|
+
return { ca: pem, servername: dnsName.slice( "DNS:".length ) };
|
|
88
|
+
}
|
|
89
|
+
const commonName = /CN=([^\n,]+)/.exec( String( certificate.subject || "" ) );
|
|
90
|
+
return { ca: pem, servername: commonName ? commonName[ 1 ].trim() : undefined };
|
|
91
|
+
} catch {
|
|
92
|
+
// Unreadable, or not a certificate. Nothing to anchor to.
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Last resort when TLS is on and no certificate is available to verify against: prove the port accepts connections.
|
|
99
|
+
*
|
|
100
|
+
* @returns {void}
|
|
101
|
+
*/
|
|
102
|
+
function probeSocket() {
|
|
103
|
+
const socket = net.connect( { host: HOST, port: port } );
|
|
104
|
+
socket.setTimeout( TIMEOUT_MS );
|
|
105
|
+
socket.on( "connect", () => { socket.destroy(); alive(); } );
|
|
106
|
+
socket.on( "timeout", () => { socket.destroy(); dead(); } );
|
|
107
|
+
socket.on( "error", dead );
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {Object} extraOptions Merged into the request options — the CA and server name for the TLS case.
|
|
112
|
+
* @returns {void}
|
|
113
|
+
*/
|
|
114
|
+
function probeHealthEndpoint( extraOptions ) {
|
|
115
|
+
const transport = useTLS ? require( "node:https" ) : require( "node:http" );
|
|
116
|
+
const request = transport.get( { host: HOST, port: port, path: "/health", ...extraOptions }, ( response ) => {
|
|
117
|
+
response.resume();
|
|
118
|
+
process.exit( response.statusCode === 200 ? 0 : 1 );
|
|
119
|
+
} );
|
|
120
|
+
request.setTimeout( TIMEOUT_MS, () => {
|
|
121
|
+
request.destroy();
|
|
122
|
+
dead();
|
|
123
|
+
} );
|
|
124
|
+
request.on( "error", dead );
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if ( !useTLS ) {
|
|
128
|
+
probeHealthEndpoint( {} );
|
|
129
|
+
} else {
|
|
130
|
+
const trust = ownCertificate();
|
|
131
|
+
if ( trust ) {
|
|
132
|
+
probeHealthEndpoint( { ca: trust.ca, servername: trust.servername } );
|
|
133
|
+
} else {
|
|
134
|
+
probeSocket();
|
|
135
|
+
}
|
|
136
|
+
}
|
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
|
-
*
|
|
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
|
-
*
|
|
63
|
-
*
|
|
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 directions — this 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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
|
@@ -274,9 +274,11 @@ module.exports.onShutDownHandler = ( instance ) => {
|
|
|
274
274
|
module.exports.resourceProtectionHandler = ( instance ) => {
|
|
275
275
|
return ( request, response, next ) => {
|
|
276
276
|
if ( instance.isUnprotectedRoute( request.url ) || instance.verifySession( request.session ) ) {
|
|
277
|
-
next();
|
|
278
|
-
}
|
|
279
|
-
|
|
277
|
+
return next();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const redirectTo = "/";
|
|
281
|
+
const refuse = () => {
|
|
280
282
|
if ( isHtmxRequest( request ) ) {
|
|
281
283
|
response.set( "HX-Redirect", redirectTo );
|
|
282
284
|
response.status( exceptions.httpCode.C_204 ).end();
|
|
@@ -285,7 +287,26 @@ module.exports.resourceProtectionHandler = ( instance ) => {
|
|
|
285
287
|
} else {
|
|
286
288
|
response.status( exceptions.httpCode.C_401 ).end();
|
|
287
289
|
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// A session that carries a user and STILL fails verification has been judged invalid — the application looked
|
|
293
|
+
// at who it belongs to and said no. Leaving it alive would mean re-deciding the same refusal on every request
|
|
294
|
+
// while the shell, which reads `auth.isAuthenticated`, goes on believing the visitor is signed in. Destroy it,
|
|
295
|
+
// so the redirect lands on a real login instead of a loop. The default `verifySession` cannot produce this
|
|
296
|
+
// case (it returns true whenever a user is present), so nothing changes for a consumer that does not override
|
|
297
|
+
// it; a destroy that fails is logged and the refusal is served regardless.
|
|
298
|
+
if ( request.session && request.session.user && typeof request.session.destroy === "function" ) {
|
|
299
|
+
logger.log( `Ending a session that failed verification for user '${ request.session.user.userID || request.session.user.employeeID }'.`, logger.logSeverity.NOTICE );
|
|
300
|
+
request.session.destroy( ( error ) => {
|
|
301
|
+
if ( error ) {
|
|
302
|
+
logger.log( "Failed to destroy a session that did not pass verification.", logger.logSeverity.WARNING, error );
|
|
303
|
+
}
|
|
304
|
+
refuse();
|
|
305
|
+
} );
|
|
306
|
+
return;
|
|
288
307
|
}
|
|
308
|
+
|
|
309
|
+
refuse();
|
|
289
310
|
};
|
|
290
311
|
};
|
|
291
312
|
|
|
@@ -702,6 +723,42 @@ module.exports.webAppHandler = ( instance ) => {
|
|
|
702
723
|
};
|
|
703
724
|
};
|
|
704
725
|
|
|
726
|
+
/**
|
|
727
|
+
* Re-derives the session's application-owned state on each request by calling {@link TiWebServer#refreshSession},
|
|
728
|
+
* then re-applies the additive `admin` allowlist role — the same order sign-in uses, so a hook that replaces
|
|
729
|
+
* `session.user.roles` cannot strand an allowlisted administrator.
|
|
730
|
+
* <br/>
|
|
731
|
+
* Mounted after the static handlers and before the application routes: a session-less request (a stylesheet, the
|
|
732
|
+
* health probe, the login page) never reaches the hook, and every request that can consult roles has passed through
|
|
733
|
+
* it first.
|
|
734
|
+
* <br/>
|
|
735
|
+
* A throwing hook is fail-closed rather than fatal: the failure is logged, the session's application roles are
|
|
736
|
+
* dropped, and the request proceeds with the `admin` role alone. Taking the whole application down because one
|
|
737
|
+
* authority lookup failed would be worse than serving it without authority — and the administrator's access is the
|
|
738
|
+
* one that exists to repair the data that broke.
|
|
739
|
+
*
|
|
740
|
+
* @method
|
|
741
|
+
* @param {TiWebServer} instance
|
|
742
|
+
* @returns {ExpressHandler}
|
|
743
|
+
* @public
|
|
744
|
+
*/
|
|
745
|
+
module.exports.sessionRefreshHandler = ( instance ) => {
|
|
746
|
+
return ( request, response, next ) => {
|
|
747
|
+
const session = request.session;
|
|
748
|
+
if ( !session || !session.user ) {
|
|
749
|
+
return next();
|
|
750
|
+
}
|
|
751
|
+
try {
|
|
752
|
+
instance.refreshSession( session, request );
|
|
753
|
+
} catch ( error ) {
|
|
754
|
+
logger.log( `Failed to refresh the session for user '${ session.user.userID || session.user.employeeID }'; continuing without application roles.`, logger.logSeverity.ERROR, error );
|
|
755
|
+
session.user.roles = [];
|
|
756
|
+
}
|
|
757
|
+
authorization.applyAdminRole( session, instance.serviceConfig?.auth?.admins );
|
|
758
|
+
next();
|
|
759
|
+
};
|
|
760
|
+
};
|
|
761
|
+
|
|
705
762
|
/**
|
|
706
763
|
* Validate Origin/Referer for non-GET/HEAD/OPTIONS requests.
|
|
707
764
|
* 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.
|
|
3
|
+
"version": "1.32.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",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -174,9 +174,17 @@ declare class TiWebServer extends ServiceConsumer {
|
|
|
174
174
|
*/
|
|
175
175
|
reportHealthy(): void;
|
|
176
176
|
/**
|
|
177
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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 directions — this 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;
|