@ti-engine/web-framework 1.21.0 → 1.24.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,88 @@
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.24.0
6
+
7
+ A configuration file change shipped in a release could never reach a deployment that had already been seeded. The
8
+ store writes a file default only when the document has never been written, and the consuming application then lets
9
+ the stored value overwrite the file value on every boot — so the file default is consulted exactly once in a
10
+ deployment's lifetime. Restore could not help either, since it replays a previous version and the oldest version
11
+ *is* the stale one. The framework now detects that difference and lets an admin apply it deliberately.
12
+
13
+ * feat(config-drift): new `#config-drift` module — a pure structural diff between a document's registered file
14
+ default and its stored value. Recurses into objects to report leaf paths, **set-diffs arrays of primitives** so a
15
+ code-list change reads as `+27 codes` rather than an opaque "changed", and compares arrays of objects atomically.
16
+ Paths use the same dot/bracket dialect as schema validation issues
17
+ * feat(config-service): `getDrift`, `listDrift` and `applyDefaults`. Applying routes through `applyEdits`, so a
18
+ file default lands validated, versioned, correlated into one change-set, in the audit feed, and restorable —
19
+ never as a side-channel write
20
+ * feat(config-service): interdependent documents apply as a **single** change-set, which is required rather than
21
+ merely convenient: a semantic validator resolves its siblings at their *pending* value, so a document whose
22
+ constraint spans another can only pass when both are applied together
23
+ * feat(admin-config-handlers): `GET /admin/config/drift`, `GET /admin/config/drift/:configKey` and
24
+ `POST /admin/config/drift/apply`, all admin-gated
25
+ * build(release): bump package version from `1.23.0` to `1.24.0`
26
+
27
+ **Note on statuses:** `absent` (never seeded) is deliberately distinct from `drifted`. A document that is registered
28
+ but never seeded is not a problem to act on, and folding the two together would flag it on every boot of a clean
29
+ install — training operators to ignore exactly the signal this feature exists to raise.
30
+
31
+ ## Version 1.23.0
32
+
33
+ Local (username/password) authentication is real. It had never been implemented: the constructor overwrote whatever
34
+ was configured with `admin`/`admin` behind a "for testing purposes only" TODO, the check was a plain `===` on both
35
+ fields, and the session user it produced carried no email — which since `competence` began resolving identity by
36
+ email meant a local sign-in could not reach an application at all.
37
+
38
+ * feat(local-user-directory): new `#local-user-directory` module — a JSON file of user records loaded on boot and
39
+ reconciled into Redis under `ti:web:auth:local-users`. Records carry `username`, `email`, `name` and a
40
+ `passwordHash`; `email` is required, because it is the field a consuming application resolves an identity by. The
41
+ file is the source of truth: a boot reconcile adds, updates and **removes**, so deleting a user revokes access
42
+ * feat(local-user-directory): scrypt password hashing via `node:crypto` — no new dependency — with a per-user random
43
+ salt and the cost parameters recorded in each hash, so they can be raised later without invalidating existing
44
+ hashes. Verification is timing-safe, and an unknown username still performs a hash computation so the login form
45
+ is not a username-enumeration oracle
46
+ * feat(auth-manager)!: **the hardcoded `admin`/`admin` pair is gone.** Local sign-ins are verified against the
47
+ directory, and `authorize()` returns a `User` carrying `userID`, `username`, `email` and `name` instead of a
48
+ random-UUID stub. Any deployment relying on the hardcoded credentials must provision a users file
49
+ * fix(auth-manager): a local user's `userID` is stable across logins. It was a fresh UUID each time, so
50
+ `auth.admins` could never match a local user by userID — only by username
51
+ * feat(build): `npm run hash-password` generates a record's hash, reading the password from **stdin** rather than
52
+ argv, which would put it in shell history and in `ps`
53
+ * feat(web-config-env): `TI_WEB_AUTH_LOCAL_USERS_PATH` overrides `auth.local.usersPath`
54
+ * fix(auth-manager): close a fail-open found by the whole-branch review — `#authenticateLocal` and `authorize()`
55
+ consulted the Redis-backed directory directly, so a users file that failed to load (missing, unreadable, or
56
+ unconfigured) still authenticated against records reconciled by an **earlier successful boot**, even while
57
+ logging that every local sign-in would be refused. A new `#localDirectoryUsable` flag is required by both
58
+ before any lookup, and is set only after a load that reconciled at least one record. `authorize()` also now
59
+ refuses a `disabled` record on its own, rather than relying on `authenticate()` having already been called
60
+ * fix(auth-manager): a Redis error during directory reconcile is now logged with only its `message`/`code` —
61
+ the raw ioredis error carries the failed command's full arguments, which for this call includes every user's
62
+ salt and scrypt hash, so logging it verbatim printed the entire directory's credential material at WARNING level
63
+ * build(release): bump package version from `1.22.0` to `1.23.0`
64
+
65
+ **Not included, and required before `local` is the sole method on an internet-facing deployment:** rate limiting,
66
+ lockout after repeated failures, and password policy.
67
+
68
+ ## Version 1.22.0
69
+
70
+ An application may now refuse a sign-in from its `augmentSession` hook, and that refusal is genuinely fail-closed.
71
+ Sign-in failures also present identically across every auth method, which local (username/password) auth needs before
72
+ it can be offered as a production option.
73
+
74
+ * fix(web-handlers): destroy the session when an augment hook throws. `session.user` is assigned in place before the
75
+ hook runs and `verifySession` only checks that it exists, so a merely-rejected session was still persisted by
76
+ express-session at response end and would have admitted the refused user
77
+ * feat(web-server): document the `augmentSession` refusal contract — throwing refuses the login, destroys the session,
78
+ and redirects to the login page carrying the exception code
79
+ * feat(web-handlers): redirect any HTML-accepting, non-HTMX **401** to `/?error=<code>`, not only `GET` requests, so a
80
+ local-auth POST failure presents exactly like an OAuth callback failure. Non-401 responses are unaffected
81
+ * feat(web-app): render the sign-in failure message on the login page. `#ti-error` was an empty element and the
82
+ `getUrlParam` helper had no call sites, so a failed sign-in previously returned a blank login form
83
+ * feat(exports): expose the authorization helpers as `@ti-engine/web-framework/authorization`, so an application can
84
+ reuse `isAdminIdentity` for its own allowlist decisions instead of reimplementing the match
85
+ * build(release): bump package version from `1.21.0` to `1.22.0`
86
+
5
87
  ## Version 1.21.0
6
88
 
7
89
  Two read-only screens the framework now provides for every consumer: **Profile** — which has been a registered
package/README.md CHANGED
@@ -18,6 +18,7 @@ The web server configuration (host, port, TLS, cookies, etc.) is normally provid
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
20
  * `TI_WEB_AUTH_METHODS` (comma-separated) **replaces** the enabled authentication methods (`auth.enabledMethods`), e.g. `openid-google` or `local,openid-google`.
21
+ * `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).
21
22
  * `TI_WEB_AUTH_ADMINS` (comma-separated) **replaces** the admin allowlist (`auth.admins`). Entries are matched against the session user's user ID, username or email, so an OpenID deployment lists emails. An explicitly empty value means *no admins*.
22
23
  * `TI_WEB_TRUSTED_ORIGINS` (comma-separated) **replaces** the trusted request origins (`trustedOrigins`) — needed behind proxies that do not present the real external origin.
23
24
  * `TI_WEB_STATIC_MAX_AGE` (whole seconds) overrides `staticCache.maxAge`. See [Static asset caching](#static-asset-caching).
@@ -26,6 +27,57 @@ The web server configuration (host, port, TLS, cookies, etc.) is normally provid
26
27
 
27
28
  OpenID Connect providers are configured with their own variables — `TI_AZURE_AUTH_CLIENT_ID` / `TI_AZURE_AUTH_CLIENT_SECRET` / `TI_AZURE_AUTH_CALLBACK_URL` / `TI_AZURE_AUTH_DISCOVERY_URL`, and the `TI_GCLOUD_AUTH_*` equivalents. A callback URL may be given either as the full absolute URL registered with the provider (`https://your-host/login/azure-callback`) or as a path (`/login/azure-callback`): the server always listens on the path, while the `redirect_uri` sent to the provider is the absolute value verbatim if one was configured, and otherwise assembled from the request's forwarded protocol/host.
28
29
 
30
+ ## Authentication and authorization
31
+
32
+ `TiWebServer#augmentSession` is the hook through which an application derives its own session roles — from an identity store, the org chart, or wherever a deployment keeps that mapping — once per login, before the framework's own additive `admin` role (`auth.admins`, see [Environment variables](#environment-variables)) is applied on top; the default is a no-op that returns the session unchanged. Throwing from the hook refuses the sign-in rather than admitting a session the application could not map to a principal: the framework destroys the freshly regenerated session so nothing usable survives the refusal, the login handler responds `401`, and the error handler sends the browser back to the login page with the exception code in the `?error=` query parameter — the same path a failed OpenID callback takes, regardless of which auth method was used.
33
+
34
+ ## Local (username/password) authentication
35
+
36
+ `local` is one of the configurable `auth.enabledMethods` sign-in methods (see [Environment variables](#environment-variables), `TI_WEB_AUTH_METHODS`). It is backed by a JSON file of user records — there is no built-in account of any kind.
37
+
38
+ ### The users file
39
+
40
+ `auth.local.usersPath` (override: `TI_WEB_AUTH_LOCAL_USERS_PATH`) points at a JSON file holding an array of records:
41
+
42
+ ```json
43
+ [
44
+ {
45
+ "username": "jdoe",
46
+ "email": "jane.doe@example.com",
47
+ "name": "Jane Doe",
48
+ "passwordHash": "scrypt$16384$8$1$<salt-base64>$<hash-base64>"
49
+ }
50
+ ]
51
+ ```
52
+
53
+ * `username`, `email`, `name` and `passwordHash` are required. `email` is required because a consuming application resolves the signed-in identity by it, the same way it would for an OpenID identity — a record with no email cannot reach an application at all.
54
+ * `userID` is optional. When omitted, one is derived from the username, so it stays stable across restarts and logins; supply it explicitly only when something else needs to match a specific value (e.g. `auth.admins`).
55
+ * `disabled: true` keeps the record (and its username) in the file while refusing every sign-in for it.
56
+
57
+ Generate `passwordHash` with the bundled CLI. It reads the password from **stdin**, never an argument, so it never lands in shell history or a process listing (`ps`), and it never echoes the password back:
58
+
59
+ ```bash
60
+ npm run hash-password -w @ti-engine/web-framework
61
+ ```
62
+
63
+ Type or pipe the password, then EOF; only the resulting hash is written to stdout.
64
+
65
+ That `npm run` form only works inside this monorepo (it is a workspace script). A consumer of the published `@ti-engine/web-framework` package has no `bin` entry to run it by name — the script ships under `bin/build/` regardless, so invoke it by its path inside `node_modules` instead:
66
+
67
+ ```bash
68
+ node ./node_modules/@ti-engine/web-framework/bin/build/hash-password.js
69
+ ```
70
+
71
+ ### The file is the source of truth
72
+
73
+ On every boot, the file is read and reconciled into the running directory: an added record starts working, a changed `passwordHash` takes effect, and — this is the point — **a record removed from the file is removed from the directory**, revoking that user's access on the next restart. Editing the file and restarting is the whole revocation mechanism; there is no separate delete action.
74
+
75
+ ### Every failure refuses rather than admits
76
+
77
+ `local` enabled with no `auth.local.usersPath` configured, a file that cannot be read, a file that is not valid JSON, or a file that yields zero valid records after validation — each of these logs a startup **WARNING** and refuses every local sign-in, rather than admitting one or falling back to a default. A failed *read* deliberately does not reconcile, so a temporarily broken volume mount leaves previously stored records untouched instead of wiping them; those records stay inert (unused) while the load keeps failing, because sign-ins are refused anyway.
78
+
79
+ **There is no rate limiting, no lockout after repeated failures, and no password policy.** Treat `local` on an internet-facing deployment as a deliberate risk until those exist.
80
+
29
81
  ## Static asset caching
30
82
 
31
83
  Everything under `/static` is served with a `Cache-Control` policy configured by the `staticCache` block:
@@ -0,0 +1,36 @@
1
+ /*
2
+ * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
+ * Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
4
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
+ * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
+ */
8
+
9
+ "use strict";
10
+
11
+ /**
12
+ * Generates a password hash for a local-users file entry.
13
+ *
14
+ * Usage: npm run hash-password -w @ti-engine/web-framework
15
+ *
16
+ * The password is read from stdin, never from an argument: an argv value lands in shell history and is visible to
17
+ * every other user on the machine through `ps`. Only the resulting hash is written to stdout — the password itself
18
+ * is never echoed, logged, or written to a file by this tool.
19
+ */
20
+
21
+ const directory = require( "#local-user-directory" );
22
+
23
+ let input = "";
24
+ process.stdin.setEncoding( "utf8" );
25
+ process.stdin.on( "data", ( chunk ) => {
26
+ input += chunk;
27
+ } );
28
+ process.stdin.on( "end", () => {
29
+ // Strip only the trailing newline a shell or editor adds; a password may legitimately contain spaces.
30
+ const password = input.replace( /\r?\n$/, "" );
31
+ if ( password.length === 0 ) {
32
+ process.stderr.write( "hash-password: no password on stdin\n" );
33
+ process.exit( 1 );
34
+ }
35
+ process.stdout.write( directory.hashPassword( password ) + "\n" );
36
+ } );
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "username": "REPLACE-ME",
4
+ "email": "replace-me@example.com",
5
+ "name": "Replace Me",
6
+ "passwordHash": "GENERATE-WITH-npm-run-hash-password"
7
+ }
8
+ ]
@@ -2,6 +2,10 @@
2
2
  "interface": {
3
3
  "default": {
4
4
  "login": {
5
+ "error-sign-in-failed": {
6
+ "en": "We couldn't sign you in. Check your credentials, or contact your administrator if your account is not set up for this application.",
7
+ "bg": "Неуспешно влизане. Проверете данните си за достъп или се свържете с администратора, ако акаунтът ви не е настроен за това приложение."
8
+ },
5
9
  "password": {
6
10
  "en": "Password",
7
11
  "bg": "Парола"
@@ -12,7 +12,12 @@
12
12
  <!-- Login card -->
13
13
  <div class="ti-login-card">
14
14
  <!-- Error message -->
15
- <div id="ti-error" class="ti-login-error"></div>
15
+ <div id="ti-error"
16
+ class="ti-login-error"
17
+ x-data="tiLoginError"
18
+ x-bind:class="{ visible: hasError }"
19
+ role="alert"
20
+ x-text-label="interface.default.login.error-sign-in-failed"></div>
16
21
 
17
22
  <!--ti-auth-method:local-->
18
23
  <!-- Local auth form -->
@@ -2549,6 +2549,10 @@ body {
2549
2549
  border-radius: var(--r-md);
2550
2550
  }
2551
2551
 
2552
+ .ti-login-error.visible {
2553
+ display: block;
2554
+ }
2555
+
2552
2556
  .ti-login-form {
2553
2557
  display: flex;
2554
2558
  flex-direction: column;
@@ -1615,6 +1615,28 @@ const configureScreenAbout = () => {
1615
1615
  };
1616
1616
  };
1617
1617
 
1618
+ /**
1619
+ * Returns a configuration object for the login screen error message.
1620
+ * <br/>
1621
+ * The login handlers answer a failed sign-in with a `303` to `/?error=<code>` (see `defaultErrorHandler`). This reads
1622
+ * that parameter and reveals the message element; the copy itself stays declarative via `x-text-label` so it localizes
1623
+ * through the normal path. Deliberately independent of the throwaway test-user panel so it survives that panel's removal.
1624
+ *
1625
+ * @method
1626
+ * @returns {Object}
1627
+ * @public
1628
+ */
1629
+ const configureLoginError = () => {
1630
+ return {
1631
+ hasError: false,
1632
+
1633
+ init() {
1634
+ const tiToolbox = Alpine.store( "tiToolbox" );
1635
+ this.hasError = Boolean( tiToolbox.getUrlParam( "error" ) );
1636
+ }
1637
+ };
1638
+ };
1639
+
1618
1640
  /**
1619
1641
  * Returns a configuration object for the login screen test user pill panel.
1620
1642
  * <br/>
@@ -1738,6 +1760,7 @@ document.addEventListener( "alpine:init", () => {
1738
1760
  Alpine.data( "tiComponentNotificationBar", configureComponentNotificationBar );
1739
1761
  Alpine.data( "tiComponentTooltip", configureComponentTooltip );
1740
1762
  Alpine.data( "tiLoginTestUserPanel", configureLoginTestUserPanel );
1763
+ Alpine.data( "tiLoginError", configureLoginError );
1741
1764
  Alpine.data( "tiScreenProfile", configureScreenProfile );
1742
1765
  Alpine.data( "tiScreenAbout", configureScreenAbout );
1743
1766
  } );
package/bin/web-server.js CHANGED
@@ -60,12 +60,18 @@ const applyWebConfigEnvOverrides = require( "#web-config-env" );
60
60
  /**
61
61
  * @typedef {Object} SettingsAuth
62
62
  * @property {string[]} enabledMethods
63
- * @property {Object} local
63
+ * @property {SettingsAuthLocal} local
64
64
  * @property {Object} oauth2
65
65
  * @property {SettingsOAuth2Client} [oauth2.azure]
66
66
  * @property {SettingsOAuth2Client} [oauth2.google]
67
67
  */
68
68
 
69
+ /**
70
+ * @typedef {Object} SettingsAuthLocal
71
+ * @property {string} [usersPath] Path to the JSON file of local user records (see `TI_WEB_AUTH_LOCAL_USERS_PATH`).
72
+ * Local sign-in refuses everyone whenever this is absent, unreadable, or yields no usable records.
73
+ */
74
+
69
75
  /**
70
76
  * @typedef {Object} SettingsOAuth2Client
71
77
  * @property {string} [clientID]
@@ -430,6 +436,11 @@ class TiWebServer extends ServiceConsumer {
430
436
  * Hook for the application to augment the freshly-authenticated session (e.g. derive domain roles from an
431
437
  * identity store or the org chart). Runs synchronously, once per login, before the framework's additive `admin`
432
438
  * role is applied. The default is a no-op. Any test-user role injection is an override of whatever the app derives.
439
+ * <br/>
440
+ * **Refusing a login.** Throwing from this hook refuses the sign-in: the framework destroys the freshly regenerated
441
+ * session (so no usable session survives the refusal), the login handler raises `401`, and the error handler
442
+ * redirects the browser to the login page with the exception code in `?error=`. Throw when the authenticated
443
+ * identity cannot be mapped to an application principal; return the session unchanged to accept it.
433
444
  *
434
445
  * @method
435
446
  * @virtual
@@ -457,6 +468,12 @@ class TiWebServer extends ServiceConsumer {
457
468
 
458
469
  /**
459
470
  * Used to set up user authorization according to the specified auth method.
471
+ * <br/>
472
+ * NOTE: This presupposes a successful, immediately preceding {@link TiWebServer#authenticate} call for the same
473
+ * credentials — it is **not** an independent authentication check. For the `local` method it builds the session
474
+ * user from the directory record named by `oidc.username`, verifying that the record exists and is not disabled
475
+ * but performing no password comparison of its own; the framework's own login route calls `authenticate` first.
476
+ * Calling this directly without that preceding step would mint a session for any known username.
460
477
  *
461
478
  * @method
462
479
  * @param {TiAuthMethod} authMethod
@@ -568,6 +585,9 @@ class TiWebServer extends ServiceConsumer {
568
585
  this.#webServer.get( "/admin/config/changes/:changeSetID", requireAdmin, adminConfigHandlers.getChange( service ) );
569
586
  this.#webServer.post( "/admin/config/changes/:changeSetID/restore", requireAdmin, adminConfigHandlers.restoreChangeSet( service ) );
570
587
  this.#webServer.get( "/admin/config/export", requireAdmin, adminConfigHandlers.exportBundle( service ) );
588
+ this.#webServer.get( "/admin/config/drift", requireAdmin, adminConfigHandlers.listDrift( service ) );
589
+ this.#webServer.get( "/admin/config/drift/:configKey", requireAdmin, adminConfigHandlers.getDrift( service ) );
590
+ this.#webServer.post( "/admin/config/drift/apply", requireAdmin, adminConfigHandlers.applyDefaults( service ) );
571
591
  }
572
592
 
573
593
  /**
@@ -18,6 +18,9 @@
18
18
  "openid-google"
19
19
  ],
20
20
  "admins": [],
21
+ "local": {
22
+ "usersPath": ""
23
+ },
21
24
  "oauth2": {
22
25
  "azure": {
23
26
  "tokenEndpointAuthMethod": "client_secret_post",
@@ -93,3 +93,34 @@ module.exports.exportBundle = ( service ) => ( request, response, next ) => {
93
93
  response.status( exceptions.httpCode.C_200 ).send( JSON.stringify( bundle, null, 2 ) );
94
94
  } ).catch( ( error ) => forward( next, error ) );
95
95
  };
96
+
97
+ /**
98
+ * `GET /admin/config/drift` — drift summaries for every registered document.
99
+ *
100
+ * @param {ConfigService} service
101
+ * @returns {ExpressHandler} The Express handler.
102
+ */
103
+ module.exports.listDrift = ( service ) => ( request, response, next ) => {
104
+ service.listDrift().then( ( drift ) => sendData( response, drift ) ).catch( ( error ) => forward( next, error ) );
105
+ };
106
+
107
+ /**
108
+ * `GET /admin/config/drift/:configKey` — one document's drift, including the full entry list.
109
+ *
110
+ * @param {ConfigService} service
111
+ * @returns {ExpressHandler} The Express handler.
112
+ */
113
+ module.exports.getDrift = ( service ) => ( request, response, next ) => {
114
+ service.getDrift( request.params.configKey ).then( ( drift ) => sendData( response, drift ) ).catch( ( error ) => forward( next, error ) );
115
+ };
116
+
117
+ /**
118
+ * `POST /admin/config/drift/apply` — applies the file defaults for the named documents as one change-set.
119
+ *
120
+ * @param {ConfigService} service
121
+ * @returns {ExpressHandler} The Express handler.
122
+ */
123
+ module.exports.applyDefaults = ( service ) => ( request, response, next ) => {
124
+ const body = request.body || {};
125
+ service.applyDefaults( body.configKeys, { adminID: adminID( request ), note: body.note } ).then( ( result ) => sendData( response, result ) ).catch( ( error ) => forward( next, error ) );
126
+ };
@@ -10,8 +10,10 @@ const tools = require( "@ti-engine/core/tools" );
10
10
  const logger = require( "@ti-engine/core/logger" );
11
11
  const exceptions = require( "@ti-engine/core/exceptions" );
12
12
  const { randomBytes } = require( "node:crypto" );
13
+ const fs = require( "node:fs" );
13
14
  const openidClient = require( "openid-client" );
14
15
  const User = require( "#user" );
16
+ const localUserDirectory = require( "#local-user-directory" );
15
17
 
16
18
  /** @import { SettingsAuth } from "#web-server" */
17
19
 
@@ -54,14 +56,48 @@ class AuthManager {
54
56
  #authSettings = {
55
57
  enabledMethods: [],
56
58
  local: {
57
- username: undefined,
58
- password: undefined
59
+ usersPath: undefined
59
60
  },
60
61
  oauth2: {}
61
62
  };
62
63
  #clientConfigOAuth2Google = {};
63
64
  #clientConfigOAuth2Azure = {};
64
65
 
66
+ // Whether the local user directory is genuinely usable: set true only after #loadLocalUserDirectory performs
67
+ // a successful reconcile that produced at least one record. Every other outcome — no 'usersPath' configured,
68
+ // an unreadable/unparseable file, a file that reconciles to zero records, or a Redis failure during reconcile
69
+ // — leaves this false. #authenticateLocal and authorize() both require it before consulting the directory,
70
+ // because localUserDirectory.findByUsername reads Redis directly: without this flag, records reconciled by
71
+ // an EARLIER successful boot would remain live and would still authenticate even though the CURRENT boot's
72
+ // log already told the operator "every local sign-in will be refused". A failed load deliberately still does
73
+ // not erase those stale Redis records (see #loadLocalUserDirectory's own doc comment) — this flag is what
74
+ // makes them inert instead of merely unmentioned.
75
+ #localDirectoryUsable = false;
76
+
77
+ // A fixed, valid encoding used only to spend comparable time on an unknown or disabled username. It corresponds
78
+ // to no usable password: the key material is random, so nothing can ever verify against it.
79
+ //
80
+ // It is ASSEMBLED, not hashed. `verifyPassword` reads N/r/p and the key length out of the encoded string and
81
+ // then derives asynchronously off the main thread, so the decoy only has to be *decodable* — the derive that
82
+ // equalizes the timing happens inside `verifyPassword` either way, at parameters identical to a real record's
83
+ // because they come from the same HASH_DEFAULTS. Calling `hashPassword` here would additionally run a
84
+ // ~100 ms blocking `scryptSync`, and it bought nothing: an earlier version paid that at class load (so every
85
+ // instance paid it, including one with 'local' disabled entirely — competence's shipped Azure-only image),
86
+ // and making it lazy only moved the same blocking cost onto the first refused login, i.e. onto a request path.
87
+ // Assembling it costs a few random bytes, so it can be eager again without the lazy-getter machinery.
88
+ //
89
+ // It MUST remain decodable: if `decodeHash` ever rejected it, `verifyPassword` would return false immediately
90
+ // without deriving, and the timing-equalization this exists for would silently stop working. A test asserts
91
+ // the decoy still round-trips through the directory's own validation.
92
+ static #timingDecoyHash = [
93
+ localUserDirectory.ALGORITHM,
94
+ localUserDirectory.HASH_DEFAULTS.N,
95
+ localUserDirectory.HASH_DEFAULTS.r,
96
+ localUserDirectory.HASH_DEFAULTS.p,
97
+ randomBytes( localUserDirectory.HASH_DEFAULTS.saltBytes ).toString( "base64" ),
98
+ randomBytes( localUserDirectory.HASH_DEFAULTS.keyBytes ).toString( "base64" )
99
+ ].join( "$" );
100
+
65
101
  /**
66
102
  * @constructor
67
103
  * @param {SettingsAuth} settings
@@ -71,14 +107,6 @@ class AuthManager {
71
107
  this.#authSettings = settings;
72
108
  }
73
109
 
74
- // Set up local authentication configuration:
75
- if ( this.isAuthEnabled( authMethodEnum.LOCAL ) ) {
76
- // TODO: For testing purposes only! Implement real local auth later!
77
- this.#authSettings.local = this.#authSettings.local || {};
78
- this.#authSettings.local.username = "admin";
79
- this.#authSettings.local.password = "admin";
80
- }
81
-
82
110
  // Set up OAuth2 configuration:
83
111
  this.#authSettings.oauth2 = this.#authSettings.oauth2 || {};
84
112
  if ( this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
@@ -113,6 +141,9 @@ class AuthManager {
113
141
  this.#dropUnconfiguredOpenIDProviders();
114
142
 
115
143
  let promises = [];
144
+ if ( this.isAuthEnabled( authMethodEnum.LOCAL ) ) {
145
+ promises.push( this.#loadLocalUserDirectory() );
146
+ }
116
147
  if ( this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
117
148
  promises.push( this.#initializeOpenIDClient( this.#authSettings.oauth2.google ).then( ( configuration ) => {
118
149
  this.#clientConfigOAuth2Google = configuration;
@@ -186,6 +217,13 @@ class AuthManager {
186
217
 
187
218
  /**
188
219
  * Used to set up user authorization according to the specified authentication method.
220
+ * <br/>
221
+ * NOTE: This presupposes a successful, immediately preceding {@link AuthManager#authenticate} call for the
222
+ * same credentials and is NOT an independent authentication check on its own — for `LOCAL` it performs no
223
+ * password verification. It refuses an absent, disabled, or (for `LOCAL`) not-yet-usable-directory record,
224
+ * but a caller that invokes it without having just authenticated bypasses password verification entirely.
225
+ * The framework's own login route always calls `authenticate()` first (see `web-handlers.js`); this method
226
+ * is public on both `AuthManager` and `TiWebServer`, so any other caller must preserve that ordering itself.
189
227
  *
190
228
  * @method
191
229
  * @param {TiAuthMethod} authMethod
@@ -198,7 +236,27 @@ class AuthManager {
198
236
  authorize( authMethod, currentUrl, oidc ) {
199
237
  switch ( authMethod ) {
200
238
  case authMethodEnum.LOCAL:
201
- return Promise.resolve( new User( { userID: `local:${ tools.getUUID() }`, username: oidc.username } ) );
239
+ // Requires the same #localDirectoryUsable flag #authenticateLocal requires see its declaration
240
+ // — so a stale Redis-backed record from an earlier successful boot cannot mint a session User
241
+ // merely because authorize() looks the username up independently of #authenticateLocal.
242
+ if ( !this.#localDirectoryUsable ) {
243
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
244
+ }
245
+ return localUserDirectory.findByUsername( oidc.username ).then( ( record ) => {
246
+ // A disabled record must be refused here too, not only by #authenticateLocal: the two
247
+ // lookups are independent reads of the same Redis-backed directory, and a reconcile that
248
+ // flips 'disabled' between them would otherwise let authorize() admit what authenticate()
249
+ // had just refused (or vice versa).
250
+ if ( !record || record.disabled === true ) {
251
+ throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 );
252
+ }
253
+ return new User( {
254
+ userID: record.userID,
255
+ username: record.username,
256
+ email: record.email,
257
+ name: record.name
258
+ } );
259
+ } );
202
260
  case authMethodEnum.OPENID_GOOGLE:
203
261
  return this.#authorizeOpenID( currentUrl, oidc, this.#clientConfigOAuth2Google );
204
262
  case authMethodEnum.OPENID_AZURE:
@@ -355,7 +413,83 @@ class AuthManager {
355
413
  }
356
414
 
357
415
  /**
358
- * Used to verify the local authentication of a request.
416
+ * Loads the configured local users file and reconciles it into the directory. Every failure path leaves the
417
+ * directory unusable and logs why, so local authentication refuses rather than admits — the same fail-soft
418
+ * stance as {@link AuthManager#dropUnconfiguredOpenIDProviders}: a bad local-users file must not take down an
419
+ * instance whose other auth method works, and must not let anyone in either.
420
+ * <br/>
421
+ * "Unusable" is not just a log line: {@link AuthManager#localDirectoryUsable} is the flag that actually makes
422
+ * it so. `localUserDirectory.findByUsername` reads Redis directly, so without this flag a record reconciled
423
+ * by an EARLIER successful boot would remain live — and would still authenticate — even on a boot where this
424
+ * method logs that every local sign-in will be refused. The flag defaults to `false` and is set `true` only
425
+ * at the very end of a successful reconcile that yielded at least one record; every failure path below
426
+ * returns (or rejects) without ever setting it, so it stays `false`.
427
+ * <br/>
428
+ * A failed read deliberately does NOT reconcile, so a broken volume mount leaves the stored records untouched
429
+ * instead of destroying them. They are inert while the load is failing — not because they are gone, but
430
+ * because {@link AuthManager#localDirectoryUsable} stays `false` and #authenticateLocal/authorize() both
431
+ * require it before ever consulting the directory.
432
+ *
433
+ * @method
434
+ * @returns {Promise}
435
+ */
436
+ #loadLocalUserDirectory() {
437
+ const usersPath = this.#authSettings.local?.usersPath;
438
+ if ( !usersPath ) {
439
+ this.#localDirectoryUsable = false;
440
+ logger.log( "Local authentication is enabled but no 'auth.local.usersPath' is configured (see TI_WEB_AUTH_LOCAL_USERS_PATH) — every local sign-in will be refused.", logger.logSeverity.WARNING );
441
+ return Promise.resolve();
442
+ }
443
+
444
+ let raw;
445
+ try {
446
+ raw = JSON.parse( fs.readFileSync( usersPath, "utf8" ) );
447
+ } catch ( error ) {
448
+ this.#localDirectoryUsable = false;
449
+ logger.log( `Could not read the local users file '${ usersPath }' — every local sign-in will be refused. Previously stored records are left untouched.`, logger.logSeverity.WARNING, exceptions.raise( error ) );
450
+ return Promise.resolve();
451
+ }
452
+
453
+ const parsed = localUserDirectory.parseRecords( raw );
454
+ parsed.problems.forEach( ( problem ) => {
455
+ logger.log( `Local users file '${ usersPath }': ${ problem }`, logger.logSeverity.WARNING );
456
+ } );
457
+ if ( parsed.records.length === 0 ) {
458
+ logger.log( `The local users file '${ usersPath }' yielded no usable records — every local sign-in will be refused.`, logger.logSeverity.WARNING );
459
+ }
460
+
461
+ return localUserDirectory.reconcile( parsed.records ).then( ( result ) => {
462
+ // Usable only when the reconcile actually produced at least one record — a file that parses cleanly
463
+ // but yields zero valid records (logged above) must refuse just as completely as one that could not
464
+ // be read at all.
465
+ this.#localDirectoryUsable = parsed.records.length > 0;
466
+ logger.log( `Local user directory reconciled: ${ result.added.length } added, ${ result.updated.length } updated, ${ result.removed.length } removed.`, logger.logSeverity.NOTICE );
467
+ } ).catch( ( error ) => {
468
+ this.#localDirectoryUsable = false;
469
+ // Log only the error's message and code — never the raw error object or an exception wrapping it.
470
+ // ioredis attaches `err.command = { name, args }` to reply errors and connection aborts, and
471
+ // `tools.errorToJSON` (invoked when the logger's data argument is an Error, including one wrapped by
472
+ // exceptions.raise) copies every own property, `command` included. For this call `args` is
473
+ // `[ "JSON.SET", localUserDirectory.CACHE_KEY, "$", <the entire directory JSON> ]`, so passing the
474
+ // raw error through here would print every local user's salt and scrypt hash at WARNING level on a
475
+ // WRONGTYPE, OOM, ACL failure, or mid-command disconnect. Do not "simplify" this back to
476
+ // `exceptions.raise( error )` or `error` directly.
477
+ logger.log( "Could not reconcile the local user directory — every local sign-in will be refused.", logger.logSeverity.WARNING, { message: error?.message, code: error?.code } );
478
+ } );
479
+ }
480
+
481
+ /**
482
+ * Verifies a local sign-in against the user directory.
483
+ * <br/>
484
+ * An unknown username still performs a hash computation against a placeholder before failing, so a missing user
485
+ * and a wrong password take comparable time. Without it the response time answers "does this username exist?",
486
+ * which turns the login form into an enumeration oracle.
487
+ * <br/>
488
+ * Requires {@link AuthManager#localDirectoryUsable} in addition to {@link AuthManager#isAuthEnabled} before
489
+ * ever calling `findByUsername` — that function reads Redis directly, so without this check a record
490
+ * reconciled by an earlier successful boot would still authenticate on a boot whose own load just failed.
491
+ * This check is a boot-time configuration gate, not a per-request secret, so it refuses immediately rather
492
+ * than through the timing-decoy path below.
359
493
  *
360
494
  * @method
361
495
  * @param {string} username
@@ -363,13 +497,20 @@ class AuthManager {
363
497
  * @returns {Promise}
364
498
  */
365
499
  #authenticateLocal( username, password ) {
366
- return new Promise( ( resolve, reject ) => {
367
- // TODO: Implement this!
368
- if ( this.isAuthEnabled( authMethodEnum.LOCAL ) && ( username === this.#authSettings.local.username && password === this.#authSettings.local.password ) ) {
369
- resolve();
370
- } else {
371
- reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
500
+ if ( !this.isAuthEnabled( authMethodEnum.LOCAL ) || !this.#localDirectoryUsable ) {
501
+ return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
502
+ }
503
+
504
+ const refuse = () => Promise.reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
505
+
506
+ return localUserDirectory.findByUsername( username ).then( ( record ) => {
507
+ if ( !record || record.disabled === true ) {
508
+ // Burn comparable time before refusing, so timing does not reveal whether the username exists.
509
+ return localUserDirectory.verifyPassword( password, AuthManager.#timingDecoyHash ).then( () => refuse() );
372
510
  }
511
+ return localUserDirectory.verifyPassword( password, record.passwordHash ).then( ( matches ) => {
512
+ return matches ? Promise.resolve() : refuse();
513
+ } );
373
514
  } );
374
515
  }
375
516