Compare commits

...

44 Commits

Author SHA1 Message Date
wmantly ffb2e99199 Merge pull request #108 from theta42/release/1.6.0
Release 1.6.0
2026-07-27 14:18:21 -04:00
wmantly 9d5f106863 Release 1.6.0: adopt @simpleworkjs/frontend messages/modal/validate
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:14:32 -04:00
wmantly 7f00d4c845 Merge pull request #107 from theta42/modernize/simpleworkjs-frontend
Adopt @simpleworkjs/frontend messages/modal/validate modules
2026-07-27 14:05:36 -04:00
wmantly 1d1d29d287 Adopt @simpleworkjs/frontend's messages/modal/validate modules
Replaces the vendored app.util.actionMessage/actionConfirm/alert (the
latter added ad hoc to fix "app.util.alert is not a function") with the
published @simpleworkjs/frontend package: app.messages.action/confirm,
app.modal.open, and app.validate.js (which also replaces the identical
vendored val.js). Gains real HTML-escaping on message content and a toast
fallback when there's no inline .actionMessage target, neither of which
the vendored code had.

app.api/app.auth/app.pubsub/app.socket in app-base.js are untouched —
they're app-specific (dual-mode callback/promise API, auth-token header
injection) and not something the generic frontend package's app.js
provides, so it isn't loaded here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 13:35:51 -04:00
wmantly 5665504bc1 Merge pull request #106 from theta42/fix/sshpublickey-oauth-parent
Fix sshPublicKey ObjectClassViolationError and blank OAuth parent dropdown
2026-07-26 23:09:57 -04:00
wmantly 2ac1c30112 Fix sshPublicKey ObjectClassViolationError and blank OAuth parent dropdown
- User.update/addSSHkey now ensure the ldapPublicKey objectClass is present
  before writing sshPublicKey, so accounts predating that objectClass
  (e.g. the bootstrap admin) no longer 500 on PUT /api/user/:uid.
- populateHostDropdown in directory.ejs was missing an `oauth` branch,
  leaving the parent-Service picker blank when adding an OAuth Integration.
- Bump to 1.5.1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 22:38:48 -04:00
wmantly 04c18eaf30 Merge pull request #105 from theta42/docs/screenshots-refresh
docs: refresh screenshots for the unified UI
2026-07-26 16:31:46 -04:00
wmantly 6835074b8b docs: refresh screenshots for the unified UI, add directory.png
Screenshots were still showing the pre-unification nav (Dashboard/Sites/
Integrations); replace with the current Users/Groups/Directory/Executive
shell and add a directory.png for the new consolidated inventory page.
Fix a couple of stale "Integrations page" / "Sites" references in the
concept docs to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 16:26:11 -04:00
wmantly 59ae30897b Merge pull request #104 from theta42/feature/ui-unification
Release 1.5.0: unified front-end UI shell
2026-07-26 00:30:05 -04:00
wmantly 94a7e07410 Release 1.5.0: unified front-end UI shell across the theta42 apps
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:21:51 -04:00
wmantly a5de279bb4 logInRedirect: keep the query string on the legacy /login/<path> form
The OIDC provider sends an unauthenticated authorize request through
/login/oauth/authorize?client_id=…&state=…; dropping the query there
loses the whole authorization request. The ?redirect= form is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:55:07 -04:00
wmantly d8b6f6e7a3 app.api.delete: accept the (url, data, callback) form formAJAX uses
formAJAX always passes the serialized form as the second argument, so a
DELETE-method form (proxy's host/DNS rows) landed its callback in the
data slot and never ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:15:07 -04:00
wmantly 208762f0d1 Unify the front-end UI shell across the theta42 apps
views/top.ejs, views/bottom.ejs and public/lib/js/app-base.js are now
byte-identical across sso-manager-node, proxy and jump-host. Everything
per-app moved into utils/ui.js, exposed to every render as `ui` via
app.locals (nav items + their group gates, footer repo/docs/ToS links,
favicon, profile/logout targets, update-banner on/off + label).

Client framework changes:
- One gating model everywhere: app-base.js reveals .group-required-<cn>
  for each of the current user user/me groups. sso-manager-node sends LDAP
  DNs in memberOf, the OIDC clients send CNs in groups; both normalise to
  CNs, and the clients isAdmin flag becomes a synthetic `admin` group, so
  proxy nav-admin items are now group-required-admin.
- user/me is fetched once per page load and cached (app.auth.loadUser);
  nav, forceLogin and group-required elements all read that one promise.
- isLoggedIn is dual-mode (Promise + node-style callback), so the async
  and callback call styles both work from one shared top.ejs.
- forceLogin no longer uses $.holdReady (removed in jQuery 4): it redirects
  to /login?redirect=<path>, and still enforces required groups.
- logOut only clears the session; the caller decides where to go next.
- post/put/delete are dual-mode Promise/callback, which also removes the
  undefined `callback2` reference that threw on a non-function callback.

Dependencies: jquery ^4.0.0 and ejs ^3.1.10 in all three apps.

sso-manager-node specifics:
- val.js adopts the shared superset (adds the target/hostname rules and
  the password policy, and fixes the let-shadowed `message` that stopped
  custom rule messages from reaching validateMessage).
- GET /api/user/me now also reports isAdmin (membership in app_sso_admin).
- public/js/app.js: $.isFunction -> typeof (removed in jQuery 4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:57:34 -04:00
wmantly b076498219 Merge pull request #103 from theta42/release/v1.4.0
Release 1.4.0
2026-07-25 16:41:49 -04:00
wmantly fc0d9104d0 Release 1.4.0: shared @simpleworkjs/* packages; fix discovery envelope drift + client_secret_hash leak
Rewire onto @simpleworkjs/directory-schema, /ldap, and /app-stack. The
directory discovery API now returns the {results} envelope via explicit
/resources, /resources/:slug, /graph, /me handlers and routes every read
through projectResource/projectResources, which unconditionally strips
client_secret_hash (and any /secret|password|privatekey/i key) and reduces
metadata to a public allowlist for non-admins — closing the leak where the ORM
serialized metadata wholesale. The dead routes/api_discovery.js (mounted after
the 404 catcher) is removed; ?group= now returns 200 instead of 404. user_ldap
+ group_ldap take escapeFilter/escapeDN + makeClient/withClient from the shared
ldap package (posix/write-side stays app-local; cert validation unchanged).
build_info unified to {buildVersion,buildHash,buildYear}; ldapts ^8.1.8. New
tests/discovery.test.js locks in the envelope + no-secrets guarantees. Lockfile
regenerated from the registry (no file:/link:).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-25 16:39:10 -04:00
wmantly 82da47cef7 Merge pull request #102 from theta42/docs/jump-host-xref
docs: cross-link the SSH jump host as a directory consumer
2026-07-23 16:23:54 -04:00
wmantly 39779f51dc docs: cross-link the SSH jump host as a directory consumer
- directory.md: new "Consumers of the directory" section explaining how
  the jump host reads the inventory (groups x host resources) to route
  SSH, and pointing at directory_spec.md §9 for planned consumers
- index.md: mention the jump host under Directory & Inventory and in
  Related projects

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:18:31 -04:00
wmantly d9a3cb6044 Merge pull request #101 from theta42/release/v1.3.2
Release 1.3.2 — fix OAuth client API client_id serialization (bootstrap-breaking)
2026-07-23 16:09:37 -04:00
wmantly 0ee6825a01 fix: OAuth client API returned client_id: undefined; unknown id 500'd
The ORM Model.toJSON() serializes only schema fields, so the mapped
client_id/scopes/redirect_uris/... that OAuthClient.get() attaches to
the wrapped Resource were stripped from GET /api/oauth/client[/:id]
responses. client_id came back undefined; the theta-env bootstrap then
POSTed /api/oauth/client/undefined/rotate and got a 500, aborting stack
bring-up whenever proxy-secrets.js lacked a usable secret.

- OAuthClient.get() now emits an explicit public toJSON (client_id, name,
  slug, scopes, redirect_uris, allowed_groups, token_lifetime, is_valid),
  deliberately omitting client_secret_hash so it can't leak over the API.
- OAuthClient.get() null-guards Resource.get() (which returns null, not
  throws) and returns a clean 404 for an unknown/undefined id instead of
  crashing on r.kind.
- Regression tests: list/get expose client_id + hide the secret hash, the
  list-then-rotate bootstrap path, and unknown-id -> 4xx not 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:06:55 -04:00
wmantly 14b6ed5ae0 Merge pull request #97 from theta42/spec/directory-consumers
Directory spec: readiness review of the five planned consumers
2026-07-23 03:37:00 -04:00
wmantly fd98854628 spec: readiness review of the five planned directory consumers
Records what each planned consumer of the directory data needs — end-user
catalog + access requests, SSH jump host, firewall rule generation/drift
testing, local DNS/mDNS, and host access control — what the current
model/API already covers, and the concrete gaps.

Verdict: the graph model is sufficient for all five. Gaps are one new
model (AccessRequest), service-token auth for the read API, documented
metadata conventions (portMappings, sshPort, dnsNames, icon, ...), and
cheap change detection (updated_on/etag). Also flags a real issue found
while auditing: /api/discovery/resources exposes full metadata —
including OAuth client_secret_hash — to any authenticated user; a
metadata privacy projection tops the work list.

Also updates the spec's stale status line (it still said "no code yet").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:33:19 -04:00
wmantly 3babf18fe4 Merge pull request #96 from theta42/release/v1.3.1
Release 1.3.1
2026-07-23 03:29:01 -04:00
wmantly d78f1dfabf Release 1.3.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:24:23 -04:00
wmantly c3c206b830 Merge pull request #95 from theta42/docs/directory-doc
Surface the Directory documentation — register in-app and link
2026-07-23 03:20:05 -04:00
wmantly 0a21dce0d7 docs: surface the Directory doc — register in-app, link from UI and site
docs/directory.md existed but was orphaned: not in the /docs registry,
not linked anywhere. Now:

- registered as /docs/directory ("Directory & Inventory")
- help icon on the Directory page header links to it (same pattern as
  users/groups/profile pages)
- linked from the docs site index feature list
- extended with the shared slug conventions (site_<name>, host_<hostname>),
  the automatic registration story (theta-env stack seeding, ldap-client
  Linux host enrollment), and the admin + read-only API surface (the
  read-only graph routes live at /api/discovery, not /api/directory).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:17:50 -04:00
wmantly 5940880d9b Merge pull request #94 from theta42/docs/ldap-not-legacy
docs: direct LDAP binds are first-class, not "legacy"
2026-07-23 02:57:20 -04:00
wmantly 17fcf2fed0 docs: direct LDAP binds are first-class, not "legacy"
Linux hosts are a primary consumer of the directory — PAM/SSSD login,
LDAP-backed sudo rules (sudoRole), and SSH public keys (openssh-lpk) —
which is exactly what the custom schemas exist for. Describe LDAPS /
StartTLS consumers as "Linux hosts and LDAP-native apps" instead of
"legacy apps" across README, DEPLOYMENT, docs, and the Dockerfile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:55:11 -04:00
wmantly ec76054e41 Merge pull request #93 from theta42/release/v1.3.0
Release 1.3.0
2026-07-23 02:25:27 -04:00
wmantly 5c0fc4f016 Release 1.3.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:22:46 -04:00
wmantly 43dae2a3eb Merge branch 'simpleworkjs' (superseded by tested ORM-port fixes on this branch)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:21:50 -04:00
wmantly 20c0a48199 ui: remove mobile phone field from user form
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:59 -04:00
wmantly 12da7140c2 test: dockerized test suite (openldap + redis + test-runner)
docker-compose.test.yml spins up the all-in-one OpenLDAP image, a
standalone Redis, and a test-runner that seeds the test user and runs
jest against them. globalSetup honors REDIS_URL; tests/setup.js
initializes the ORM and flushes test Redis keys before the run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:59 -04:00
wmantly dfd5f46095 feat: OAuth client management API at /api/oauth/client
CRUD + secret rotation for OAuth clients (app_sso_oauth_admin group),
backed by the Resource model. Normalizes form-style string inputs
(newline-separated redirect_uris/allowed_groups, space-separated
scopes, bracketed token_lifetime fields).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:59 -04:00
wmantly 5dcc75195c fix: complete ORM port — token/oauth-client API mismatches, use published orm 0.2.8
- Use published @simpleworkjs/orm ^0.2.8 (fixes redis adapter write path)
  and model-redis ^1.6.0 instead of a local file: link that broke docker
  npm ci with a misleading "no lockfile" error.
- OtpToken.issue/verify: replace nonexistent find()/listDetail() with
  list({where}).
- routes/auth.js: ImpersonationToken.listDetail() -> list({where}).
- routes/token.js: drop listDetail() call; 404 on missing token instead
  of returning {results: null} with 200 (orm get() returns null, does
  not throw like model-redis Table.get did).
- OAuthClient: Resource has no is_valid column, so every client read as
  disabled and all /oauth/authorize requests 400'd — validity now lives
  in metadata (absent = valid). Also generate a unique slug on create
  (Resource.slug is required+unique) and use Resource.get() for lookup.
- User.login: 401 cleanly when neither uid nor username is supplied.
- models/index.js: log ORM init and surface init failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 02:20:49 -04:00
wmantly 12a99b550c Bump @simpleworkjs/orm to 0.2.8 to fix redis bugs 2026-07-22 23:24:41 -04:00
wmantly 4ce5a5f492 Monkey-patch RedisAdapter 2026-07-22 22:41:57 -04:00
wmantly c84141b2f3 Restore package-lock.json 2026-07-22 22:37:51 -04:00
wmantly f76d93d840 Fix User.login credentials check and flush Redis before tests 2026-07-22 22:37:45 -04:00
wmantly e910a492ba Fix Token API compatibility 2026-07-22 22:30:26 -04:00
wmantly 1ef868e23c Fix ORM API usages in Token subclasses 2026-07-22 22:26:26 -04:00
wmantly e11e39c23a fix: initialize ORM in tests 2026-07-22 22:17:23 -04:00
wmantly b91089ad4c fix: use published @simpleworkjs/orm instead of local path 2026-07-22 22:11:11 -04:00
wmantly 95ae50a924 fix: generate package-lock.json with node 20 for CI 2026-07-22 22:06:31 -04:00
wmantly 0076784fae fix: sync package-lock.json version 2026-07-22 22:03:53 -04:00
59 changed files with 3339 additions and 1014 deletions
+3 -3
View File
@@ -20,9 +20,9 @@
!directory_spec.md !directory_spec.md
!docs/**/*.md !docs/**/*.md
# Tests # Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
nodejs/tests/ # nodejs/tests/
nodejs/*.test.js # nodejs/*.test.js
# Host dependency tree — let the image run a clean `npm ci`. Also avoids # Host dependency tree — let the image run a clean `npm ci`. Also avoids
# copying platform-wrong native modules (e.g. bcrypt built for the host OS). # copying platform-wrong native modules (e.g. bcrypt built for the host OS).
+90
View File
@@ -4,6 +4,96 @@ All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [1.6.0] - 2026-07-27
### Changed
- **Adopted `@simpleworkjs/frontend`'s `app.messages`, `app.modal`, and `app.validate` modules**, replacing the vendored `app.util.actionMessage`/`actionConfirm`/`alert` in `public/lib/js/app-base.js` and the vendored `public/lib/js/val.js`. Message content is now HTML-escaped (the vendored `alert()` this replaces had no escaping), and `app.messages.action` falls back to a page-wide toast when there's no inline `.actionMessage` target. `app.api`/`app.auth`/`app.pubsub`/`app.socket` are untouched — they're app-specific (dual-mode callback/promise API, `auth-token` header injection) and not something the frontend package's generic `app.js` provides.
## [1.5.1] - 2026-07-27
### Fixed
- **`PUT /api/user/:uid` 500'd with `ObjectClassViolationError` (LDAP `0x41`) when setting `sshPublicKey`** on any account created before the `ldapPublicKey` auxiliary objectClass was added to new-user creation (e.g. the bootstrap `admin` account). `User.update`'s `sshPublicKey` handling and `User.addSSHkey` (`nodejs/models/user_ldap.js`) now add the `ldapPublicKey` objectClass first (ignoring `TypeOrValueExistsError` if already present), the same pattern already used for `dateOfBirth`/`theta42Person`.
- **OAuth Integration parent dropdown was blank.** `populateHostDropdown` in `nodejs/views/directory.ejs` only built options for `kind === 'host'` and `kind === 'service'` — there was no branch for `kind === 'oauth'`, so choosing "OAuth Integration" in the Directory's add-resource modal left the parent-Service picker empty except the placeholder. Added the missing branch.
## [1.5.0] - 2026-07-26
### Changed
- **Unified the front-end UI shell across the three theta42 apps.** `views/top.ejs`, `views/bottom.ejs` and `public/lib/js/app-base.js` are now byte-identical in sso-manager-node, proxy and jump-host, so the apps look and behave the same and a shell change lands in one edit per repo instead of three divergent ones. Everything that differs between the apps moved into a new `nodejs/utils/ui.js`, exposed to every render as `ui` via `app.locals`: nav items and the groups that may see them, footer repo/license/docs/Terms links, favicon, the profile and post-logout targets, and whether the update banner exists at all.
- **One nav-gating model everywhere.** `app-base.js` reveals `.group-required-<cn>` elements for each group the current user is in, read from `GET /api/user/me`. sso-manager-node reports LDAP DNs in `memberOf` and the OIDC clients report CNs in `groups`; both normalise to CNs client-side, and the clients' effective-rights `isAdmin` flag is exposed as a synthetic `admin` group — so one gating model covers a group-based provider and boolean-admin clients without either app learning the other's response shape.
- **`GET /api/user/me` is fetched once per page load and cached** (`app.auth.loadUser`). The nav, per-view `forceLogin` and every group-gated element read that one promise instead of issuing their own request.
- `app.auth.isLoggedIn` is dual-mode: it returns a Promise **and** invokes an optional node-style callback, so the async and callback call styles both work against one shared `top.ejs`.
- `app.auth.forceLogin` no longer uses `$.holdReady` (removed in jQuery 4). An unauthenticated user is redirected to `/login?redirect=<path>`; group requirements are still enforced, and `logOut` now only clears the session, leaving the destination to the caller (`ui.logoutRedirect`).
- Dependency alignment across all three apps: `jquery` `^4.0.0` and `ejs` `^3.1.10`.
### Fixed
- **`app.api.delete` dropped its callback when called by `formAJAX`.** `formAJAX` always passes the serialized form as the second argument, so a DELETE-method form's callback landed in the data slot and never ran. `delete` now accepts both `(url, callback)` and `(url, data, callback)`.
- **`app.api.post`/`put` referenced an undefined `callback2`** and threw when handed a non-function callback. Both are now dual-mode Promise/callback.
- **The login page's "reveal the card once we know you're logged out" branch threw** (`Cannot read properties of null`) whenever the logged-in check answered before the parser reached that element — which it always did without a stored token. It now runs on DOM ready.
- **`logInRedirect` on the legacy `/login/<path>` form kept only the path.** The OIDC provider routes an unauthenticated authorization request through `/login/oauth/authorize?client_id=…&state=…`; dropping the query there loses the entire authorization request. The suffix form now preserves its query string.
### Fixed (sso-manager-node)
- `public/lib/js/val.js` shadowed `message` with `let` inside `validateField`, so a custom rule's return value never reached `validateMessage` and the caller always saw the generic length message. Resolved by adopting the shared validator, which also brings the `target`/`hostname` rules and the real password policy (>= 8 chars, and either 12+ or 3 of 4 character classes) to this app.
- `public/js/app.js` used `$.isFunction`, removed in jQuery 4.
### Added (sso-manager-node)
- `GET /api/user/me` now also reports `isAdmin` (membership in `app_sso_admin`), the single effective-rights flag the shared UI shell gates the update banner on. Group-level gating still reads `memberOf`.
### Verified
- Browser-verified against a full theta-env stack (sso-manager + proxy + jump-host): every top-level page renders with a clean console; nav gating is correct for admin and non-admin; `forceLogin`'s onboarding and group gates fire; `val.js` blocks a weak password and accepts a strong one through a real form submit; the DELETE-method forms work; and the OIDC login round trip (authorize with PKCE -> login -> consent -> callback -> token fragment) completes on both OIDC clients.
## [1.4.0] - 2026-07-25
### Security
- **The directory discovery API leaked OAuth `client_secret_hash` (and any secret-ish metadata key) to every authenticated caller.** `Resource` doesn't override `toJSON`, so the ORM serialized `metadata` wholesale — including the `client_secret_hash` stored on `kind:'oauth'` resources — across `GET /api/discovery/resources`, `/graph`, `/me`, `/resources/:slug`, and the directory-admin `GET /api/directory-admin/resources`. Every discovery read endpoint and the admin list now route through `projectResource`/`projectResources` from `@simpleworkjs/directory-schema`, which unconditionally strips secret keys (anything matching `/secret|password|privatekey/i`, including `client_secret_hash`) and, for non-directory-admins, reduces metadata to a public allowlist. Admins never receive `client_secret_hash` either.
### Fixed
- **Directory discovery envelope drift.** `routes/discovery.js` (the `autoRouter(Resource)` mounted live at `app.js:87`) returned **bare arrays**, not the `{ results: [...] }` envelope the directory contract specifies — so jump-host's `data.results || []` collapsed every per-group query to `[]` and no user could bridge. Discovery is now served by explicit `/resources`, `/resources/:slug`, `/graph`, `/me` handlers that all return the `{ results }` envelope. The dead `routes/api_discovery.js` (mounted at `app.js:112`, *after* the 404 catcher) and its mount were removed.
- `GET /api/discovery/resources?group=<cn>` now returns 200 with `{ results: [...] }` instead of 404 (the autoRouter's `search` supported `?group=`, but the route was effectively unreachable for jump-host's call pattern).
### Added
- Adopted the shared `@simpleworkjs/*` packages published under the simpleworkjs org:
- `@simpleworkjs/directory-schema` — the directory contract: the `kind` enum, `Resource`/`ResourceEdge`/`ResourceGroup` field defs, the `{ results }` envelope, the security projection (`projectResource`/`projectResources`/`isDirectoryAdmin`), and the discovery client. `models/resource.js` imports the field defs; the discovery + directory-admin routes use the projection.
- `@simpleworkjs/ldap``models/user_ldap.js` and `models/group_ldap.js` now take `escapeFilter`/`escapeDN` and `makeClient`/`withClient` from the shared package (via local wrappers that pass `conf`); sso keeps its rich `User.get`/`Group.get`/`User.login`/`User.addSSHkey` (posix/write-side stays app-local). sso's `makeClient` passes no `tlsOptions`, so cert validation is unchanged.
- `@simpleworkjs/app-stack` — unified `build_info` (`{buildVersion, buildHash, buildYear}`) and the `static-modules` mounting helper. `utils/build_info.js` and the static-modules loop in `routes/index.js` use the shared helpers.
- New `tests/discovery.test.js` (jest + supertest, runs under the docker harness): locks in the `{ results }` envelope on `/resources`, `/graph`, `/me`, `/resources/:slug`, the `?group=` 200-regression, and the no-`client_secret_hash`/no-secret-key guarantee for every caller.
### Changed
- Dependency alignment: `ldapts` `^8.1.2``^8.1.8`. The new `@simpleworkjs/*` deps resolve from the npm registry (`^1.0.0`); no `file:`/`link:` entries in the lockfile, so `npm ci` is clean in docker builds.
- `build_info` export shape changed from `{commit, version}` to `{buildVersion, buildHash, buildYear}` (the shared shape used by all three apps).
## [1.3.2] - 2026-07-23
### Fixed
- **OAuth client management API returned `client_id: undefined` on every GET.** The ORM's `Model.toJSON()` only serializes schema fields, so the mapped `client_id`/`scopes`/`redirect_uris`/… that `OAuthClient.get()` attaches to the wrapped Resource were stripped from `GET /api/oauth/client` and `GET /api/oauth/client/:id` responses. The theta-env bootstrap (which lists clients and rotates by the returned `client_id`) then called `/api/oauth/client/undefined/rotate` and got a 500, aborting stack bring-up when `proxy-secrets.js` had no usable secret. `OAuthClient.get()` now emits an explicit public JSON shape (and deliberately omits `client_secret_hash`, so the secret hash no longer leaks over the API).
- `OAuthClient.get()` no longer 500s on an unknown/`undefined` client id: `Resource.get()` returns `null` (it doesn't throw), which was dereferenced as `r.kind`. It now returns a clean 404.
## [1.3.1] - 2026-07-23
### Added
- The Directory documentation (`docs/directory.md`) is now surfaced: registered in-app at `/docs/directory` ("Directory & Inventory"), help-linked from the Directory page header, and linked from the docs-site index. Extended with the shared slug conventions (`site_<name>`, `host_<hostname>` — as used by ldap-client and the theta-env seed), the automatic-registration story (theta-env stack seeding, ldap-client Linux host enrollment), and the API surface (admin at `/api/directory-admin`, read-only graph at `/api/discovery`).
### Changed
- Direct LDAP binds are described as first-class, not "legacy", across README, DEPLOYMENT.md, docs, and the Dockerfile: Linux hosts are a primary consumer of the directory (PAM/SSSD login, LDAP-backed `sudo` via `sudoRole`, SSH public keys via openssh-lpk) — exactly what the custom schemas exist for.
## [1.3.0] - 2026-07-23
### Added
- **OAuth client management API** at `/api/oauth/client` (group `app_sso_oauth_admin`): list, create, update, delete, and rotate-secret for OAuth clients, backed by the Resource model. Accepts form-style string inputs (newline-separated `redirect_uris`/`allowed_groups`, space-separated `scopes`).
- **Dockerized test suite**: `docker-compose -f docker-compose.test.yml up --build` spins up OpenLDAP + Redis + a test-runner that seeds the test user and runs the full jest suite (174 tests) against them. `tests/globalSetup.js` honors `REDIS_URL`.
### Fixed
- Completed the model-redis → `@simpleworkjs/orm` port that shipped half-finished in 1.2.1:
- `OtpToken.issue`/`verify` called nonexistent `find()`/`listDetail()` — every OTP login 500'd.
- Impersonation create/revoke called nonexistent `ImpersonationToken.listDetail()` — both endpoints 500'd.
- `OAuthClient` read `is_valid` from the Resource model, which has no such column — every client evaluated as disabled and **all `/oauth/authorize` requests were rejected with 400**. Client validity now lives in `metadata` (absent = valid).
- `OAuthClient.add` didn't set the required-unique `Resource.slug`; clients now get a slug derived from the client name.
- `GET /api/token/:name/:token` returned `{results: null}` with 200 for unknown tokens (orm `get()` returns null instead of throwing); now 404s.
- `User.login` returns a clean 401 instead of crashing when neither `uid` nor `username` is supplied.
- Depend on published `@simpleworkjs/orm` ^0.2.8 and `model-redis` ^1.6.0 instead of a local `file:` link that broke `npm ci` in docker builds.
### Changed
- Removed the Mobile Phone field from the user create/edit form.
## [1.2.1] - 2026-07-22 ## [1.2.1] - 2026-07-22
### Added ### Added
+3 -3
View File
@@ -120,7 +120,7 @@ bare-metal / advanced standalone use; most deployments should use the file.
- Health check: `http://localhost:3001/health``{"status":"ok"}` - Health check: `http://localhost:3001/health``{"status":"ok"}`
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration` - OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host) - LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host)
- LDAPS (for legacy apps / direct binds): `ldaps://<host>:636` (TLS) - LDAPS (direct binds: Linux hosts, LDAP-native apps): `ldaps://<host>:636` (TLS)
### API tokens (personal access tokens) ### API tokens (personal access tokens)
@@ -483,8 +483,8 @@ netstat -tlnp | grep 389
2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The 2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The
bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain + bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain +
StartTLS); port 389 is not mapped to the host by default so LAN clients can't StartTLS); port 389 is not mapped to the host by default so LAN clients can't
bind in cleartext. Direct-LDAP apps (legacy services, `theta42/proxy`) should bind in cleartext. Direct-LDAP consumers (Linux hosts, LDAP-native apps,
use `ldaps://…:636` or StartTLS. `theta42/proxy`) should use `ldaps://…:636` or StartTLS.
3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't 3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't
set `JWT_SECRET`, issued tokens invalidate on container recreation. set `JWT_SECRET`, issued tokens invalidate on container recreation.
4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front 4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front
+2 -1
View File
@@ -131,7 +131,8 @@ COPY ops/schema/openssh-lpk.schema /etc/openldap/schema/openssh-lpk.schema
# 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy) # 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy)
# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only # 389: LDAP (plain + StartTLS) — used internally by the app; map to host only
# if you want LAN clients to bind without TLS (not recommended). # if you want LAN clients to bind without TLS (not recommended).
# 636: LDAPS — for legacy apps / direct LDAP binds over the network (TLS) # 636: LDAPS — direct LDAP binds over the network (TLS): Linux host auth
# (PAM/SSSD, sudo, SSH keys) and LDAP-native apps
EXPOSE 3001 389 636 EXPOSE 3001 389 636
# Health check # Health check
+50
View File
@@ -0,0 +1,50 @@
# Test-runner image for SSO Manager.
#
# Installs all dependencies (including dev) and bundles the app code plus
# the seed script. The entrypoint waits for LDAP + Redis, seeds the test
# user, then runs whatever command is given (default: npm test).
FROM node:20-alpine
# Install OpenLDAP clients (ldapadd, ldapsearch) and bash for the seed script
RUN apk add --no-cache openldap-clients bash
WORKDIR /app
# Copy and install dependencies (including devDependencies for jest/supertest)
COPY nodejs/package*.json ./
RUN npm ci
# Copy the application source
COPY nodejs/app.js ./
COPY nodejs/bin ./bin
COPY nodejs/conf ./conf
COPY nodejs/controller ./controller
COPY nodejs/middleware ./middleware
COPY nodejs/models ./models
COPY nodejs/routes ./routes
COPY nodejs/services ./services
COPY nodejs/utils ./utils
COPY nodejs/views ./views
COPY nodejs/public ./public
COPY nodejs/tests ./tests
# SQLite database directory (config/inventory.sqlite for Resource model's ORM)
RUN mkdir -p /app/config
# Files expected at the flattened /app path (see Dockerfile.openldap notes)
COPY tos.md /tos.md
COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY API.md /API.md
COPY directory_spec.md /directory_spec.md
COPY docs /docs
# Seed script and utility
COPY test_seed.js ./test_seed.js
COPY test/seed-test-user.sh /usr/local/bin/seed-test-user
RUN chmod +x /usr/local/bin/seed-test-user
# Default command: seed the test user, then run the test suite
CMD ["sh", "-c", "seed-test-user && npm test"]
+5 -4
View File
@@ -41,9 +41,10 @@ phone-home, no hosted control plane, and no per-user pricing.
- **Web management UI** — manage users, groups, and OAuth clients from a - **Web management UI** — manage users, groups, and OAuth clients from a
browser; invite and password-reset flows over email; user self-service for browser; invite and password-reset flows over email; user self-service for
profile and API tokens. profile and API tokens.
- **LDAPS for legacy apps** — apps that bind LDAP directly (Gitea, Emby, and - **Direct LDAP binds** — Linux hosts (PAM/SSSD login, LDAP-backed `sudo`
anything else that speaks LDAP) use LDAPS (636) or StartTLS against the same rules, SSH public keys via openssh-lpk) and LDAP-native apps (Gitea, Emby,
directory, so you don't maintain a second user database for them. and anything else that speaks LDAP) use LDAPS (636) or StartTLS against the
same directory, so you don't maintain a second user database for them.
- **Personal access tokens** — any user can mint a long-lived bearer token to - **Personal access tokens** — any user can mint a long-lived bearer token to
drive the management API from scripts or CI, scoped to their own permissions. drive the management API from scripts or CI, scoped to their own permissions.
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run - **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
@@ -151,7 +152,7 @@ details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
┌────────────────────────┐ ┌────────────────────────┐
│ OpenLDAP (slapd) │ │ OpenLDAP (slapd) │
│ - users / groups │ │ - users / groups │
│ - LDAPS :636 │─── legacy apps bind directly │ - LDAPS :636 │─── Linux hosts + LDAP apps bind directly
│ - StartTLS :389 │ │ - StartTLS :389 │
└────────────────────────┘ └────────────────────────┘
``` ```
+149 -2
View File
@@ -1,8 +1,10 @@
# Home-Lab Directory / Inventory — Design Spec # Home-Lab Directory / Inventory — Design Spec
Status: **Draft / agreed direction** (no code yet) Status: **Implemented** (v1.2.1+: model, admin API, UI; v1.3.x: automatic
registration from theta-env + ldap-client). §9 adds the planned-consumer
readiness review.
Owner: wmantly Owner: wmantly
Last updated: 2026-07-02 Last updated: 2026-07-23
--- ---
@@ -220,3 +222,148 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
for auth, SQL for inventory. for auth, SQL for inventory.
4. **Read-visibility policy:** confirm option (a) vs (b) in §5. 4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
5. **Service token scope:** Currently `ApiToken` shares the creator's full permissions. A future enhancement could scope tokens specifically to the Directory API. 5. **Service token scope:** Currently `ApiToken` shares the creator's full permissions. A future enhancement could scope tokens specifically to the Directory API.
---
## 9. Planned consumers — data-model & API readiness
Five consumers the directory data should be able to power. None are being
built yet; this section records what each needs, what already exists, and the
gaps to close so the model/API never paints us into a corner.
The recurring theme: **the graph model itself (Resource / ResourceEdge /
ResourceGroup + LDAP groups) is sufficient for all five.** The gaps are
(a) one new model (access requests), (b) machine-to-machine auth for the read
API, (c) documented metadata conventions instead of new columns, and
(d) change detection for the drift/sync consumers.
### 9.1 End-user exploration ("Netflix-style" catalog + request access)
A user browses everything that exists — part advertisement, part
documentation — sees what they already have, and requests access to the rest.
Already there:
- `/api/discovery/me` (`getMyAccess`) — the "My Services" half.
- `Resource.owner` + `<slug>_access` / `<slug>_admin` ResourceGroup links —
who approves, and which group an approval means joining.
- The Notification model — the approval-request delivery mechanism.
Gaps:
1. **Catalog projection with metadata privacy.** `/api/discovery/resources`
returns full `metadata` to any authenticated user — including the OAuth
kind's `client_secret_hash`, and operator notes that may name internal
IPs. Needed: a per-kind public projection (name, description, kind,
subType, icon, address, hasAccess, requestable) and a private-key
convention for the rest (e.g. only `app_sso_directory_admin` sees full
metadata). This is a **fix worth doing before any catalog UI exists**.
2. **`AccessRequest` model** — the one genuinely new model:
`{id, uid, resourceId, groupCn, status: pending|approved|denied, note,
requestedOn, decidedBy, decidedOn}`. Approval = LDAP group add + notify.
Endpoints: user POST/GET own; resource owner / directory admin
list/approve/deny.
3. **Catalog metadata conventions**: `icon`, `tagline` (card-length blurb),
`requestable: false` for resources that shouldn't be advertised.
### 9.2 SSH jump host (`username_-_{hostname-or-ip}@publicHost`)
A public jump host parses the target out of the SSH username, checks the user
may reach that host, and proxies the connection (WinSCP-friendly: one
username string, no interactive menu needed — though an interactive picker on
plain `username@` login is the same query).
Already there:
- Hosts carry `ip` (and `host_<hostname>` slugs to resolve by name).
- Access is already group-based (`<slug>_access`), checkable via LDAP alone —
the jump host can run entirely off LDAP (SSSD) + one directory query.
- User SSH keys are in LDAP (openssh-lpk) — the jump host authenticates the
real user without local accounts.
Gaps:
1. **Machine auth for the access query.** The jump host must ask "may user X
reach host Y" / "list hosts user X may reach" *about another user*.
`getMyAccess` only answers for the calling user. Needed: a
service-token-authenticated endpoint (`GET
/api/discovery/access/:uid[/:slug]`). `ServiceToken` already exists and
is even linked to a resource (`resource_id`) — what's missing is an auth
middleware that accepts it and a permission rule ("service tokens may
read access info, scoped read-only").
2. **Connection metadata conventions** on hosts: `sshPort` (default 22),
optional `fqdn` (when IP is dynamic), optional `jumpVia` edge relation if
multi-hop topologies ever appear.
3. Document the username grammar (`{uid}_-_{host-slug-or-ip}`) here so the
seed/ldap-client keep host slugs DNS-safe (they already are: slugify
strips everything but `[a-z0-9-]`).
### 9.3 Firewall port-forward rules (build / update / drift-test)
An automation renders the public firewall's forwarding table from the
directory, applies it, and alerts on drift in either direction.
Already there:
- `metadata.port` / `metadata.externalPort` / `metadata.ip` /
`metadata.isExternalReachable` — the core mapping data, already seeded for
the stack's own services.
Gaps:
1. **Port-mapping convention is too thin for real rules**: no protocol, no
multi-port services. Adopt `metadata.portMappings: [{proto: "tcp"|"udp",
external: n, internal: n, comment}]` as the authoritative form
(`port`/`externalPort` stay as the simple single-mapping case).
2. **Drift detection needs cheap change polling**: an `updated_on` timestamp
on resources surfaced in the graph API, or a graph-level etag/hash, so
the runner can poll without diffing full payloads. (The ORM already
publishes create/update events internally — a future push feed can ride
that; polling comes first.)
3. Same **service-token read auth** as 9.2 — automation must not run on a
human's session token.
### 9.4 Local DNS / mDNS
A DNS (or mDNS advertiser) zone is generated from the directory: hosts get
A records from `metadata.ip`, services get CNAMEs/records from their
addresses, sites map to zones.
Already there:
- `host_<hostname>` + `ip` covers A records; `site_<name>` is a natural zone
boundary; service `address` yields names.
Gaps:
1. **Name conventions**: `metadata.dnsNames: []` for extra aliases, and a
documented rule for which name wins (slug vs `address` hostname). TTL
only if someone actually needs per-record TTLs — default is fine.
2. Same **change detection** as 9.3 (poll `updated_on` / etag; push later).
3. Nothing else — this consumer is nearly free once 9.3's conventions land.
### 9.5 Access control for hosts
Who may log in to / sudo on which machine, driven by the directory.
Already there — this is the original point of the system:
- `<slug>_access` / `<slug>_admin` groups are auto-provisioned per host;
ldap-client configures SSSD/PAM against the directory; `sudoRole` and
openssh-lpk schemas cover sudo and SSH keys.
Gaps:
1. **Close the loop in ldap-client**: joined hosts should set an SSSD access
filter (`access_provider = ldap`, filter on `host_<hostname>_access`
membership) so directory group membership *is* login permission, not just
identity. Today the registration exists but enforcement is host-side
convention.
2. **`accessLevel` granularity**: ResourceGroup's `member`/`owner` maps to
login/admin today; if finer roles emerge (e.g. `login` vs `sudo` vs
`admin`), extend the enum — the join-table shape already supports it.
### 9.6 Consolidated work list (model/API only, no consumers)
Ordered by how much they unblock:
1. **Metadata privacy projection** on the read API (blocks 9.1; fixes the
`client_secret_hash` exposure regardless of any consumer).
2. **Service-token auth for `/api/discovery/*`** + `access/:uid` endpoint
(blocks 9.2, 9.3; ServiceToken model already exists).
3. **`AccessRequest` model + endpoints** (blocks 9.1's request half).
4. **Metadata conventions doc entries** (`sshPort`, `portMappings`,
`dnsNames`, `icon`, `tagline`, `requestable`) in `docs/directory.md`
conventions, not schema changes; the json column already holds them.
5. **`updated_on` in graph output / graph etag** (blocks drift/DNS
freshness; trivial once surfaced).
+81
View File
@@ -0,0 +1,81 @@
# Docker Compose for running the SSO Manager test suite.
#
# Spins up:
# ldap — OpenLDAP + Redis (all-in-one image, slapd + redis only, no app)
# redis — Standalone Redis for the app's model/token storage
# test-runner — Seeds the test user, then runs `npm test`
#
# Usage:
# docker compose -f docker-compose.test.yml up --build
# # Or to run a specific test file:
# docker compose -f docker-compose.test.yml run --rm test-runner npx jest tests/auth.test.js
#
# The LDAP service uses the same Dockerfile.openldap image as production but
# overrides the command to only start slapd + redis (the entrypoint handles
# slapd.conf generation, directory initialization, and Redis startup before
# running the given command — "sleep infinity" keeps it alive).
#
# The test-runner connects to ldap:389 and redis:6379 via Docker networking.
# app_* env vars override conf/secrets.js (highest precedence in
# @simpleworkjs/conf), so the production secrets.js is never read.
services:
ldap:
build:
context: .
dockerfile: Dockerfile.openldap
environment:
- LDAP_BASE_DN=dc=test,dc=local
- LDAP_ADMIN_PASS=secret
- ORG_NAME=Test SSO
# The entrypoint starts slapd + redis, then runs whatever command is given.
# "sleep infinity" keeps the container alive so the test-runner can connect.
command: ["sleep", "infinity"]
healthcheck:
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:389 -b '' -s base '(objectClass=*)' >/dev/null 2>&1"]
interval: 2s
timeout: 3s
retries: 20
start_period: 5s
volumes:
- ldap-data:/var/lib/ldap
- ldap-certs:/etc/openldap/certs
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 15
test-runner:
build:
context: .
dockerfile: Dockerfile.test-runner
environment:
# Tell the app which environment it's in (loads conf/test.js for Redis prefix)
- NODE_ENV=test
# LDAP — point at the ldap service container
- app_ldap__url=ldap://ldap:389
- app_ldap__bindDN=cn=admin,dc=test,dc=local
- app_ldap__bindPassword=secret
- app_ldap__userBase=ou=people,dc=test,dc=local
- app_ldap__groupBase=ou=groups,dc=test,dc=local
# Redis — point at the redis service container
- app_redis__redisConf__url=redis://redis:6379
# Also used by tests/globalSetup.js (direct Redis client, not @simpleworkjs/conf)
- REDIS_URL=redis://redis:6379
# JWT secret (required by the app, not sensitive in test)
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
# App name
- app_name=Test SSO
depends_on:
ldap:
condition: service_healthy
redis:
condition: service_healthy
volumes:
ldap-data:
ldap-certs:
+1 -1
View File
@@ -46,7 +46,7 @@ on, just like anyone else's.
A **group** is just a named list of accounts, used to control access. This A **group** is just a named list of accounts, used to control access. This
app has a handful of built-in groups that grant admin powers (e.g. only app has a handful of built-in groups that grant admin powers (e.g. only
people in the `app_sso_admin` group can see the Users/Groups/Integrations people in the `app_sso_admin` group can see the Users/Groups/Directory/Executive
pages at all), but you can also make your own groups for any app you pages at all), but you can also make your own groups for any app you
connect — say, a group listing everyone who should be allowed into your connect — say, a group listing everyone who should be allowed into your
photo server. Once a group exists, add or remove members from the photo server. Once a group exists, add or remove members from the
+1 -1
View File
@@ -28,7 +28,7 @@ what matters practically is the handful of concepts below.
## What's a "client"? ## What's a "client"?
Every app you connect is registered here as a **client** — a single entry Every app you connect is registered here as a **client** — a single entry
on the Integrations page representing that one app. Registering a client in the Directory representing that one app. Registering a client
gives you a **Client ID** and **Client Secret**: think of these like a gives you a **Client ID** and **Client Secret**: think of these like a
username and password, but for the *app itself* rather than for a person. username and password, but for the *app itself* rather than for a person.
You paste them into the other app's own "Single Sign-On" or "OIDC" setup You paste them into the other app's own "Single Sign-On" or "OIDC" setup
+48
View File
@@ -57,3 +57,51 @@ Resources carry a flexible `metadata` JSON object that can store essential conte
## Navigating the UI ## Navigating the UI
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it. The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
## Slug conventions
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
- **Sites**: `site_<name>` — e.g. `site_local`, `site_us-east`
- **Hosts**: `host_<hostname>` — e.g. `host_pve1`, `host_web01`
- **Services/apps**: a plain slug or `app_<name>` — e.g. `sso-manager`, `app_emby`
The auto-created LDAP groups derive from the slug (`<slug>_access` / `<slug>_admin`), so keep slugs stable once access groups are in use.
## Automatic registration
You don't have to build the graph by hand — the theta42 tooling registers itself:
### The stack itself (theta-env)
[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys:
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
- the proxy's auto-registered **OAuth client**, linked under its service
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
### Linux hosts (ldap-client)
The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_<hostname>` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location.
## Consumers of the directory
The inventory graph isn't just documentation — other components read it to make decisions:
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that resolves which downstream machines a user may reach from their LDAP groups × the directory's `host` resources (`GET /api/discovery/resources?group=<cn>`), then bridges them in. The `host_<hostname>` slugs and `host_<slug>_access` groups this directory creates are exactly what it keys off; a host's `metadata.ip` / `metadata.sshPort` tell it where to connect. So a machine registered here (by theta-env or ldap-client) becomes reachable through the jump host the moment a user is in its access group.
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
## API
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 221 KiB

+9 -4
View File
@@ -22,10 +22,11 @@ one command).
## Screenshots ## Screenshots
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a> <a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Executive dashboard" width="49%"></a>
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a> <a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a> <a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth clients" width="49%"></a> <a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory" width="49%"></a>
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth client (edit view)" width="49%"></a>
*(click any screenshot to view full size)* *(click any screenshot to view full size)*
@@ -52,11 +53,13 @@ backend, that's the niche.
- **Web management UI** — users, groups, and OAuth clients from a browser; - **Web management UI** — users, groups, and OAuth clients from a browser;
invite and password-reset flows over email; self-service profile + API invite and password-reset flows over email; self-service profile + API
tokens. tokens.
- **LDAPS for legacy apps** — anything that binds LDAP directly (Gitea, - **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
Emby, …) uses LDAPS/StartTLS against the same directory. via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
directory.
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or - **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
run the pieces separately via `app_*` env config. run the pieces separately via `app_*` env config.
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites. - **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
## Get it ## Get it
@@ -76,5 +79,7 @@ That's the standalone quick start. For the full set of install options
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware - **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
reverse proxy, designed to sit in front of this SSO. reverse proxy, designed to sit in front of this SSO.
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that
uses this SSO's directory to decide who may reach which machine.
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO - **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
Manager and the proxy together with one command. Manager and the proxy together with one command.
+3 -2
View File
@@ -14,8 +14,9 @@ description: SSO Manager's bundled OpenLDAP directory — schema, service accoun
SSO Manager runs an OpenLDAP directory holding your users and groups. The app SSO Manager runs an OpenLDAP directory holding your users and groups. The app
authenticates against it over `localhost:389` (inside the all-in-one container) authenticates against it over `localhost:389` (inside the all-in-one container)
and exposes **LDAPS** (`ldaps://…:636`, TLS) for legacy apps that bind LDAP and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
directly — Gitea, Emby, the theta42/proxy, etc. directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
theta42/proxy, etc.
## Directory layout ## Directory layout
+2
View File
@@ -64,6 +64,8 @@ Clients are managed directly from the **Directory** tab in the web UI. They are
> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group. > All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="Editing an OAuth client resource" width="80%"></a>
## Scopes ## Scopes
| Scope | Claims / access | | Scope | Claims / access |
+6 -3
View File
@@ -61,6 +61,11 @@ app.set('trust proxy', 1);
app.set('views', path.join(__dirname, 'views')); app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs'); app.set('view engine', 'ejs');
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
// Set as an app local so every res.render has it, including routes that don't
// spread the routers' `values` object.
app.locals.ui = require('./utils/ui');
// Have express server static content( images, CSS, browser JS) from the public // Have express server static content( images, CSS, browser JS) from the public
// local folder. maxAge is short since this is the app's own JS/CSS, which // local folder. maxAge is short since this is the app's own JS/CSS, which
// changes on every deploy and isn't cache-busted/fingerprinted. // changes on every deploy and isn't cache-busted/fingerprinted.
@@ -95,6 +100,7 @@ app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
// OAuth 2.0 / OpenID Connect // OAuth 2.0 / OpenID Connect
app.use('/oauth', oauthRouter); app.use('/oauth', oauthRouter);
app.use('/api/oauth', middleware.auth, oauthApiRouter); app.use('/api/oauth', middleware.auth, oauthApiRouter);
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
app.get('/.well-known/openid-configuration', discovery); app.get('/.well-known/openid-configuration', discovery);
@@ -107,9 +113,6 @@ app.use(function(req, res, next) {
next(err); next(err);
}); });
// Discovery API
app.use('/api/discovery', middleware.auth, require('./routes/api_discovery'));
// Error handling // Error handling
app.use(function(err, req, res, next) { app.use(function(err, req, res, next) {
const SILENT_404S = ['/.well-known/']; const SILENT_404S = ['/.well-known/'];
+7 -33
View File
@@ -3,44 +3,18 @@
const { Client, Attribute, Change } = require('ldapts'); const { Client, Attribute, Change } = require('ldapts');
const { LRUCache } = require('lru-cache'); const { LRUCache } = require('lru-cache');
const conf = require('@simpleworkjs/conf').ldap; const conf = require('@simpleworkjs/conf').ldap;
// Connection + escaping from the shared @simpleworkjs/ldap package. Local
// Escape a value used inside an LDAP search filter (RFC 4515). // wrappers preserve the no-arg call signatures; see user_ldap.js for rationale.
function escapeLDAPSearchValue(val) { const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
return String(val) const escapeLDAPSearchValue = escapeFilter;
.replace(/\\/g, '\\5c') const escapeLDAPDNValue = escapeDN;
.replace(/\*/g, '\\2a')
.replace(/\(/g, '\\28')
.replace(/\)/g, '\\29')
.replace(/\0/g, '\\00');
}
// Escape a value used in an LDAP DN (RFC 4514). Defensive: usernames/cns
// are normally alphanumeric, but this prevents metacharacter injection.
function escapeLDAPDNValue(val) {
return String(val)
.replace(/\\/g, '\\\\')
.replace(/,/g, '\\,')
.replace(/\+/g, '\\+')
.replace(/"/g, '\\"')
.replace(/</g, '\\<')
.replace(/>/g, '\\>')
.replace(/;/g, '\\;')
.replace(/=/g, '\\=')
.replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match);
}
function makeClient() { function makeClient() {
return new Client({ url: conf.url }); return _makeClient(conf);
} }
async function withClient(fn) { async function withClient(fn) {
const client = makeClient(); return _withClient(conf, fn);
try {
await client.bind(conf.bindDN, conf.bindPassword);
return await fn(client);
} finally {
await client.unbind().catch(() => {});
}
} }
async function getGroups(client, member){ async function getGroups(client, member){
+15 -7
View File
@@ -23,13 +23,21 @@ async function initORM() {
}; };
ormConf.redis = conf.redis; ormConf.redis = conf.redis;
await init({ console.log('[initORM] Starting ORM initialization...');
conf: { orm: ormConf }, try {
models: [ await init({
Resource, ResourceEdge, ResourceGroup, conf: { orm: ormConf },
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken models: [
] Resource, ResourceEdge, ResourceGroup,
}); Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
console.log('[initORM] ORM initialized successfully');
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
} catch (err) {
console.error('[initORM] ORM initialization failed:', err.message);
throw err;
}
} }
module.exports.initORM = initORM; module.exports.initORM = initORM;
+54 -7
View File
@@ -16,11 +16,18 @@ class OAuthClient {
const raw_secret = crypto.randomUUID(); const raw_secret = crypto.randomUUID();
const client_id = crypto.randomUUID(); const client_id = crypto.randomUUID();
const client_secret_hash = await bcrypt.hash(raw_secret, 10); const client_secret_hash = await bcrypt.hash(raw_secret, 10);
// Generate a unique slug from the client name
let slug = data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'oauth-client';
// Ensure uniqueness by appending a suffix if needed
const existing = await Resource.list({ where: { slug } });
if (existing.length) slug = `${slug}-${client_id.slice(0, 8)}`;
const r = await Resource.create({ const r = await Resource.create({
id: client_id, id: client_id,
kind: 'oauth', kind: 'oauth',
name: data.name, name: data.name,
slug: slug,
description: data.description || '', description: data.description || '',
owner: data.created_by, owner: data.created_by,
metadata: { metadata: {
@@ -37,10 +44,21 @@ class OAuthClient {
return r; return r;
} }
static async get(client_id) { static async get(client_id) {
const resources = await Resource.list({ where: { id: client_id, kind: 'oauth' } }); const notFound = () => {
if (!resources.length) throw new Error('OAuthClient not found'); const e = new Error('OAuthClient not found');
e.status = 404;
const r = resources[0]; return e;
};
let r;
try {
r = await Resource.get(client_id);
} catch (_) {
throw notFound();
}
// Resource.get() returns null (does not throw) for a missing id —
// guard it so a bad/undefined client_id is a clean 404, not a
// "Cannot read properties of null (reading 'kind')" 500.
if (!r || r.kind !== 'oauth') throw notFound();
// Map metadata to top-level properties to satisfy routes/oauth.js without rewriting it // Map metadata to top-level properties to satisfy routes/oauth.js without rewriting it
r.client_id = r.id; r.client_id = r.id;
r.client_secret_hash = r.metadata.client_secret_hash; r.client_secret_hash = r.metadata.client_secret_hash;
@@ -48,6 +66,8 @@ class OAuthClient {
r.scopes = r.metadata.scopes || ['openid', 'profile', 'email', 'groups']; r.scopes = r.metadata.scopes || ['openid', 'profile', 'email', 'groups'];
r.allowed_groups = r.metadata.allowed_groups || []; r.allowed_groups = r.metadata.allowed_groups || [];
r.token_lifetime = r.metadata.token_lifetime || { ...defaultLifetime }; r.token_lifetime = r.metadata.token_lifetime || { ...defaultLifetime };
// Resource has no is_valid column; validity lives in metadata (absent = valid)
r.is_valid = r.metadata.is_valid !== false;
r.verifySecret = async (secret) => bcrypt.compare(secret, r.client_secret_hash); r.verifySecret = async (secret) => bcrypt.compare(secret, r.client_secret_hash);
r.rotateSecret = async () => { r.rotateSecret = async () => {
@@ -57,6 +77,29 @@ class OAuthClient {
return raw_secret; return raw_secret;
}; };
// The ORM Model.toJSON() only serializes schema fields, so the mapped
// properties above (client_id, scopes, redirect_uris, …) would be
// stripped from any res.json() — that's why GET /api/oauth/client
// returned client_id: undefined and the bootstrap's rotate blew up.
// Emit the public shape explicitly. client_secret_hash is deliberately
// omitted so it never leaks over the API.
r.toJSON = function () {
return {
client_id: r.id,
id: r.id,
kind: r.kind,
name: r.name,
slug: r.slug,
owner: r.owner,
description: r.description,
redirect_uris: r.redirect_uris,
scopes: r.scopes,
allowed_groups: r.allowed_groups,
token_lifetime: r.token_lifetime,
is_valid: r.is_valid,
};
};
// proxy update to handle metadata correctly // proxy update to handle metadata correctly
const originalUpdate = r.update.bind(r); const originalUpdate = r.update.bind(r);
r.update = async (data) => { r.update = async (data) => {
@@ -64,11 +107,11 @@ class OAuthClient {
if (data.scopes !== undefined) r.metadata.scopes = data.scopes; if (data.scopes !== undefined) r.metadata.scopes = data.scopes;
if (data.allowed_groups !== undefined) r.metadata.allowed_groups = data.allowed_groups; if (data.allowed_groups !== undefined) r.metadata.allowed_groups = data.allowed_groups;
if (data.token_lifetime !== undefined) r.metadata.token_lifetime = data.token_lifetime; if (data.token_lifetime !== undefined) r.metadata.token_lifetime = data.token_lifetime;
if (data.is_valid !== undefined) r.metadata.is_valid = data.is_valid;
const updateData = { metadata: r.metadata }; const updateData = { metadata: r.metadata };
if (data.name !== undefined) updateData.name = data.name; if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description; if (data.description !== undefined) updateData.description = data.description;
if (data.is_valid !== undefined) updateData.is_valid = data.is_valid;
return originalUpdate(updateData); return originalUpdate(updateData);
}; };
@@ -81,6 +124,10 @@ class OAuthClient {
return Promise.all(resources.map(r => this.get(r.id))); return Promise.all(resources.map(r => this.get(r.id)));
} }
static async listDetail() {
return this.list();
}
static async verifySecret(client_id, secret) { static async verifySecret(client_id, secret) {
const client = await this.get(client_id); const client = await this.get(client_id);
return client.verifySecret(secret); return client.verifySecret(secret);
+2 -2
View File
@@ -95,7 +95,7 @@ class OtpToken extends Token {
} }
static async issue(uid, method) { static async issue(uid, method) {
const existing = await this.find({uid}); const existing = await this.list({where: {uid}});
for (const t of existing) { for (const t of existing) {
if (t.is_valid) await t.update({is_valid: false}); if (t.is_valid) await t.update({is_valid: false});
} }
@@ -104,7 +104,7 @@ class OtpToken extends Token {
} }
static async verify(uid, code) { static async verify(uid, code) {
const tokens = await this.listDetail({uid}); const tokens = await this.list({where: {uid}});
const match = tokens.find(t => t.is_valid && !t.isExpired && t.code === code); const match = tokens.find(t => t.is_valid && !t.isExpired && t.code === code);
if (!match) return null; if (!match) return null;
await match.update({is_valid: false}); await match.update({is_valid: false});
+42 -31
View File
@@ -9,6 +9,14 @@ const {Token, InviteToken, PasswordResetToken} = require('./token');
const {Group} = require('./group_ldap'); const {Group} = require('./group_ldap');
const {UserVerification} = require('./verification'); const {UserVerification} = require('./verification');
const conf = require('@simpleworkjs/conf').ldap; const conf = require('@simpleworkjs/conf').ldap;
// Connection + escaping come from the shared @simpleworkjs/ldap package. The
// wrappers below preserve this file's no-arg call signatures (makeClient() /
// withClient(fn)) so no call site changes; sso's makeClient passes no
// tlsOptions, which the shared client forwards as undefined — identical to the
// previous `new Client({ url: conf.url })`.
const { makeClient: _makeClient, withClient: _withClient, escapeFilter, escapeDN } = require('@simpleworkjs/ldap');
const escapeLDAPSearchValue = escapeFilter;
const escapeLDAPDNValue = escapeDN;
function hashPasswordSSHA512(password) { function hashPasswordSSHA512(password) {
const salt = crypto.randomBytes(8); const salt = crypto.randomBytes(8);
@@ -23,40 +31,11 @@ const cache = new LRUCache({
}); });
function makeClient() { function makeClient() {
return new Client({ url: conf.url }); return _makeClient(conf);
} }
async function withClient(fn) { async function withClient(fn) {
const client = makeClient(); return _withClient(conf, fn);
try {
await client.bind(conf.bindDN, conf.bindPassword);
return await fn(client);
} finally {
await client.unbind().catch(() => {});
}
}
// Helper to escape LDAP filter values (crucial for security)
function escapeLDAPSearchValue(val) {
return val.replace(/\\/g, '\\5c')
.replace(/\*/g, '\\2a')
.replace(/\(/g, '\\28')
.replace(/\)/g, '\\29')
.replace(/\0/g, '\\00');
}
// Escape a value used in an LDAP DN (RFC 4514).
function escapeLDAPDNValue(val) {
return String(val)
.replace(/\\/g, '\\\\')
.replace(/,/g, '\\,')
.replace(/\+/g, '\\+')
.replace(/"/g, '\\"')
.replace(/</g, '\\<')
.replace(/>/g, '\\>')
.replace(/;/g, '\\;')
.replace(/=/g, '\\=')
.replace(/^\s|\s$/g, match => match === ' ' ? '\\ ' : match);
} }
// Compute the next available uid/gidNumber: the highest existing value below // Compute the next available uid/gidNumber: the highest existing value below
@@ -494,6 +473,19 @@ User.update = async function(data){
} }
if(data.sshPublicKey){ if(data.sshPublicKey){
// Ensure the auxiliary objectClass is present before setting the attribute
// -- accounts created before ldapPublicKey was added to addPosixAccount's
// objectclass list (e.g. the bootstrap admin) won't have it yet.
try {
await client.modify(this.dn, [
new Change({
operation: 'add',
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
}),
]);
} catch(e) {
if(e.name !== 'TypeOrValueExistsError') throw e;
}
await client.modify(this.dn, [ await client.modify(this.dn, [
new Change({ new Change({
operation: 'replace', operation: 'replace',
@@ -805,6 +797,19 @@ User.addSSHkey = async function(data) {
let result; let result;
try { try {
await withClient(async (client) => { await withClient(async (client) => {
// Ensure the auxiliary objectClass is present before setting the attribute
// -- accounts created before ldapPublicKey was added to addPosixAccount's
// objectclass list (e.g. the bootstrap admin) won't have it yet.
try {
await client.modify(user.dn, [
new Change({
operation: 'add',
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
}),
]);
} catch(e) {
if (e.name !== 'TypeOrValueExistsError') throw e;
}
await client.modify(user.dn, [ await client.modify(user.dn, [
new Change({ new Change({
operation: 'add', operation: 'add',
@@ -891,6 +896,12 @@ User.invite = async function(data = {}){
User.login = async function(data){ User.login = async function(data){
try{ try{
if (!data.uid && !data.username) {
let error = new Error('Invalid Credentials, login failed.');
error.name = 'LDAPLoginFailed';
error.status = 401;
throw error;
}
let user = await this.get(data.uid || data.username); let user = await this.get(data.uid || data.username);
const loginClient = makeClient(); const loginClient = makeClient();
+1598 -345
View File
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.2.1", "version": "1.6.0",
"description": "A very simple LDAP management and SSO system", "description": "A very simple LDAP management and SSO system",
"author": [ "author": [
{ {
@@ -23,8 +23,12 @@
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/orm": "file:../../../simpleworkjs/orm", "@simpleworkjs/directory-schema": "^1.0.0",
"@simpleworkjs/frontend": "^0.2.5",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"compression": "^1.8.1", "compression": "^1.8.1",
@@ -33,12 +37,12 @@
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"extend": "^3.0.2", "extend": "^3.0.2",
"jq-repeat": "^2.2.0", "jq-repeat": "^2.2.0",
"jquery": "^3.7.1", "jquery": "^4.0.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"ldapts": "^8.1.2", "ldapts": "^8.1.8",
"lru-cache": "^11.5.1", "lru-cache": "^11.5.1",
"marked": "^9.1.6", "marked": "^9.1.6",
"model-redis": "^0.4.0", "model-redis": "^1.6.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"nodemailer": "^9.0.0", "nodemailer": "^9.0.0",
+1 -1
View File
@@ -396,7 +396,7 @@ app.impersonate = (function(app){
app.token = (function(app){ app.token = (function(app){
function list(name, callack){ function list(name, callack){
if($.isFunction(name)){ if(typeof name === 'function'){
callack = name; callack = name;
name = ''; name = '';
} }
+303 -164
View File
@@ -1,3 +1,12 @@
// Shared client framework for the theta42 apps.
//
// This file is byte-identical across sso-manager-node, proxy and jump-host —
// per-app behaviour comes from the server (the `ui` locals in views/top.ejs and
// the /api/user/me response), never from edits to this file. Edit all three
// copies together.
//
// jQuery 4 safe: no $.isFunction, no $.holdReady.
var app = {}; var app = {};
app.pubsub = (function(){ app.pubsub = (function(){
@@ -45,7 +54,7 @@ app.pubsub = (function(){
app.socket = (function(app){ app.socket = (function(app){
// $.getScript('/socket.io/socket.io.js') // $.getScript('/socket.io/socket.io.js')
// <script type="text/javascript" src="/socket.io/socket.io.js"></script> // <script type="text/javascript" src="/socket.io/socket.io.js"></script>
var socket; var socket;
$(document).ready(function(){ $(document).ready(function(){
socket = io({ socket = io({
@@ -75,11 +84,17 @@ app.socket = (function(app){
app.api = (function(app){ app.api = (function(app){
var baseURL = '/api/' var baseURL = '/api/'
function post(url, data, callback){ // post/put/delete are dual-mode: pass a callback for the node-style
if (!$.isFunction(callback)) { // (error, data, status) form, or omit it to get a Promise that resolves
return new Promise((resolve, reject) => { // with the parsed body and rejects with the error body. get/options return
// the jqXHR, which is itself thenable, so `await app.api.get(...)` works.
function body(method, url, data, callback){
if(typeof callback !== 'function'){
return new Promise(function(resolve, reject){
$.ajax({ $.ajax({
type: 'POST', url: baseURL+url, type: method,
url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() }, headers: { 'auth-token': app.auth.getToken() },
data: JSON.stringify(data), data: JSON.stringify(data),
contentType: 'application/json; charset=utf-8', contentType: 'application/json; charset=utf-8',
@@ -88,9 +103,11 @@ app.api = (function(app){
}); });
} }
return $.ajax({ return $.ajax({
type: 'POST', type: method,
url: baseURL+url, url: baseURL+url,
headers:{ 'auth-token': app.auth.getToken() }, headers:{
'auth-token': app.auth.getToken()
},
data: JSON.stringify(data), data: JSON.stringify(data),
contentType: "application/json; charset=utf-8", contentType: "application/json; charset=utf-8",
dataType: "json", dataType: "json",
@@ -104,40 +121,27 @@ app.api = (function(app){
}); });
} }
function post(url, data, callback){
return body('POST', url, data, callback);
}
function put(url, data, callback){ function put(url, data, callback){
if (!$.isFunction(callback)) { return body('PUT', url, data, callback);
return new Promise((resolve, reject) => {
$.ajax({
type: 'PUT', url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() },
data: JSON.stringify(data),
contentType: 'application/json; charset=utf-8',
dataType: 'json',
}).done(resolve).fail(function(xhr){ reject(xhr.responseJSON || {}); });
});
}
return $.ajax({
type: 'PUT',
url: baseURL+url,
headers:{ 'auth-token': app.auth.getToken() },
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "json",
complete: function(res, text){
callback(
text !== 'success' ? res.statusText : null,
JSON.parse(res.responseText),
res.status
);
}
});
} }
function remove(url, callback){ // Called both as (url, callback) and — from formAJAX, which always passes
if (!$.isFunction(callback)) { // the serialized form as the second argument — as (url, data, callback).
return new Promise((resolve, reject) => { // No request body is sent either way.
function remove(url, data, callback){
if(typeof data === 'function'){
callback = data;
data = undefined;
}
if(typeof callback !== 'function'){
return new Promise(function(resolve, reject){
$.ajax({ $.ajax({
type: 'DELETE', url: baseURL+url, type: 'DELETE',
url: baseURL+url,
headers: { 'auth-token': app.auth.getToken() }, headers: { 'auth-token': app.auth.getToken() },
contentType: 'application/json; charset=utf-8', contentType: 'application/json; charset=utf-8',
dataType: 'json', dataType: 'json',
@@ -147,7 +151,9 @@ app.api = (function(app){
return $.ajax({ return $.ajax({
type: 'DELETE', type: 'DELETE',
url: baseURL+url, url: baseURL+url,
headers:{ 'auth-token': app.auth.getToken() }, headers:{
'auth-token': app.auth.getToken()
},
contentType: "application/json; charset=utf-8", contentType: "application/json; charset=utf-8",
dataType: "json", dataType: "json",
complete: function(res, text){ complete: function(res, text){
@@ -202,7 +208,10 @@ app.api = (function(app){
})(app) })(app)
app.auth = (function(app){ app.auth = (function(app){
var user = {}; // One in-flight/cached GET /api/user/me per page load. Every gating
// decision (nav items, per-view forceLogin, group-required elements) reads
// this same promise instead of re-fetching.
var userPromise = null;
function setToken(token){ function setToken(token){
localStorage.setItem('APIToken', token); localStorage.setItem('APIToken', token);
@@ -216,35 +225,70 @@ app.auth = (function(app){
try{ try{
return await app.api.get('user/me'); return await app.api.get('user/me');
}catch(error){ }catch(error){
if(error?.status === 401) return null; if(error && error.status === 401) return null;
throw error throw error;
} }
} }
// Cached current user, or false when there's no token at all. Callers that
// need a fresh copy (after a login or a profile change) pass force.
function loadUser(force){
if(force || !userPromise){
userPromise = getToken() ? getUser() : Promise.resolve(null);
userPromise = userPromise.then(function(user){
app.auth.user = app.auth.perms = user || null;
return user;
});
}
return userPromise;
}
// The apps report group membership two ways: sso-manager-node returns LDAP
// DNs in `memberOf`, the OIDC clients return plain CNs in `groups`. Both
// normalise to a list of CNs. `isAdmin` (the clients' effective-rights flag)
// is exposed as a synthetic `admin` group so one gating model covers both.
function groupCNs(user){
var raw = (user && (user.memberOf || user.groups)) || [];
if(!Array.isArray(raw)) raw = [raw];
var names = raw.map(function(group){
return String(group).split(',')[0].replace(/^cn=/i, '');
});
if(user && user.isAdmin && names.indexOf('admin') === -1) names.push('admin');
return names;
}
async function memberOf(groupNameToFind, user){ async function memberOf(groupNameToFind, user){
try{ user = user || await loadUser();
user = user || await app.auth.asyncUser; if(!user) return false;
groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind] groupNameToFind = Array.isArray(groupNameToFind) ? groupNameToFind : [groupNameToFind];
for(let group of user.memberOf){ return groupCNs(user).some(function(group){
group = group.split(',ou=groups')[0].replace('cn=', ''); return groupNameToFind.includes(group);
if(groupNameToFind.includes(group)) return true; });
}
return false;
}catch(error){
throw(error);
}
} }
async function isLoggedIn(){ // True when the logged-in user is a global admin (per user/me). Sync — only
if(getToken()){ // meaningful once isLoggedIn/forceLogin has resolved.
user = await app.auth.asyncUser; function isAdmin(){
return user; return !!(app.auth.perms && app.auth.perms.isAdmin);
}else{ }
return false;
// Dual-mode: returns a Promise resolving to the user (or false), and calls
// an optional node-style callback with the same result.
function isLoggedIn(callback){
var promise = loadUser().then(function(user){
return user || false;
});
if(typeof callback === 'function'){
promise.then(function(user){
callback(null, user);
}, function(error){
callback(error, false);
});
} }
return promise;
} }
function logIn(args, callback){ function logIn(args, callback){
@@ -252,62 +296,125 @@ app.auth = (function(app){
if(data.login){ if(data.login){
setToken(data.token); setToken(data.token);
} }
loadUser(true);
callback(error, !!data.token); callback(error, !!data.token);
}); });
} }
// Clears the session only — the caller decides where to go next (the nav's
// Log Out button uses ui.logoutRedirect).
function logOut(callback){ function logOut(callback){
localStorage.removeItem('APIToken'); localStorage.removeItem('APIToken');
location.replace(`/login${location.href.replace(location.origin, '')}`); userPromise = null;
callback(); app.auth.user = app.auth.perms = null;
if(typeof callback === 'function') callback();
} }
// Constrain a redirect target to a same-origin absolute path. Rejects
// absolute URLs (open redirect), protocol-relative "//host" and "/\host",
// and non-path schemes like "javascript:" (XSS). Falls back to "/".
function safeInternalPath(path){
if(typeof path !== 'string' || path.charAt(0) !== '/'
|| path.charAt(1) === '/' || path.charAt(1) === '\\'){
return '/';
}
return path;
}
// Consume an app token handed back by the OIDC callback via the URL
// fragment (#token=…&redirect=…). Stores it, strips the fragment, and
// forwards to the intended page. Returns true if a token was consumed.
function consumeTokenFragment(){
if(!location.hash) return false;
var params = new URLSearchParams(location.hash.replace(/^#/, ''));
var token = params.get('token');
if(!token) return false;
setToken(token);
// redirect comes from the URL fragment (attacker-controllable); only
// allow a same-origin path so it can't become an open redirect / XSS.
var redirect = safeInternalPath(params.get('redirect') || '/');
// Drop the token from the address bar before navigating on.
history.replaceState(null, '', location.pathname + location.search);
window.location.href = redirect;
return true;
}
// Page-level gate. jQuery 4 removed $.holdReady, so an unauthenticated or
// unauthorised user is kept off the page by a redirect / an error panel
// rather than by pausing document ready.
//
// `requiredGroups` is a group CN or an OR-list of them; the synthetic
// `admin` group covers the OIDC clients' isAdmin flag.
async function forceLogin(requiredGroups){ async function forceLogin(requiredGroups){
$.holdReady(true); var user = await loadUser();
if(!await app.auth.isLoggedIn()) app.auth.logOut(function(){});
if(!user){
logOut(function(){});
location.replace('/login?redirect=' + encodeURIComponent(
location.pathname + location.search
));
return false;
}
if(user.onboardingRequired && location.pathname !== '/onboarding'){ if(user.onboardingRequired && location.pathname !== '/onboarding'){
location.replace('/onboarding'); location.replace('/onboarding');
return false;
} }
if(requiredGroups){ if(requiredGroups && !await memberOf(requiredGroups, user)){
if(!await memberOf(requiredGroups)){ app.messages.action(
console.log("Does not have permission!!!") `<h1>
app.util.actionMessage( <i class="fa-solid fa-triangle-exclamation"></i>
`<h1> <b>You do not have permission to be here.</b>
<i class="fa-solid fa-triangle-exclamation"></i> <i class="fa-solid fa-triangle-exclamation"></i>
<b>You do not have permission to be here.</b> </h1>`,
<i class="fa-solid fa-triangle-exclamation"></i> $('#spa-shell'),
</h1>`, 'danger',
$('#spa-shell'), );
'danger', throw new Error("User does not have permission");
);
throw new Error("User does not have permission");
}
} }
$.holdReady(false); return user;
} }
// Where to go after a successful login: the ?redirect= query param, or the
// legacy /login/<path> suffix form, constrained to a same-origin path. The
// suffix form keeps its query string — /login/oauth/authorize?client_id=…
// is how the OIDC provider sends an unauthenticated user through login.
function logInRedirect(){ function logInRedirect(){
window.location.href = location.href.replace(location.origin+'/login', '') || '/' var params = new URLSearchParams(location.search);
var target = params.get('redirect')
|| location.href.replace(location.origin + '/login', '')
|| '/';
window.location.href = safeInternalPath(target);
} }
return { return {
getToken: getToken, getToken: getToken,
setToken: setToken, setToken: setToken,
getUser: getUser,
loadUser: loadUser,
groupCNs: groupCNs,
memberOf: memberOf,
isAdmin: isAdmin,
isLoggedIn: isLoggedIn, isLoggedIn: isLoggedIn,
safeInternalPath: safeInternalPath,
consumeTokenFragment: consumeTokenFragment,
user: null,
perms: null,
logIn: logIn, logIn: logIn,
logOut: logOut, logOut: logOut,
forceLogin, forceLogin,
logInRedirect, logInRedirect,
getUser,
memberOf,
} }
})(app); })(app);
app.auth.asyncUser = app.auth.getUser();
// Back-compat alias for views that awaited the cached user directly.
Object.defineProperty(app.auth, 'asyncUser', {
get: function(){ return app.auth.loadUser(); },
});
app.user = (function(app){ app.user = (function(app){
function list(callback){ function list(callback){
@@ -338,6 +445,72 @@ app.user = (function(app){
})(app); })(app);
// Local (app-managed) permissions and groups. Only the OIDC-client apps serve
// these endpoints; the calls are inert elsewhere.
app.permission = (function(app){
function list(callback){
app.api.get('permission/', function(error, data){
callback(error, data);
});
}
function subjects(callback){
app.api.get('permission/subjects', function(error, data){
callback(error, data);
});
}
function add(args, callback){
app.api.post('permission/', args, function(error, data){
callback(error, data);
});
}
function remove(id, callback){
app.api.delete('permission/' + encodeURIComponent(id), function(error, data){
callback(error, data);
});
}
return {list, subjects, add, remove};
})(app);
app.group = (function(app){
function list(callback){
app.api.get('group/', function(error, data){
callback(error, data);
});
}
function add(args, callback){
app.api.post('group/', args, function(error, data){
callback(error, data);
});
}
function remove(name, callback){
app.api.delete('group/' + encodeURIComponent(name), function(error, data){
callback(error, data);
});
}
function addMember(name, username, callback){
app.api.post('group/' + encodeURIComponent(name) + '/members', {username}, function(error, data){
callback(error, data);
});
}
function removeMember(name, username, callback){
app.api.delete('group/' + encodeURIComponent(name) + '/members/' + encodeURIComponent(username), function(error, data){
callback(error, data);
});
}
return {list, add, remove, addMember, removeMember};
})(app);
app.util = (function(app){ app.util = (function(app){
function getUrlParameter(name){ function getUrlParameter(name){
@@ -347,65 +520,15 @@ app.util = (function(app){
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
}; };
function actionMessage(message, $targetPassed, type, callback){ // escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
message = message || ''; // app.util.escapeHtml and app.messages.action/confirm.
function escapeHtml(s){
let $target = $targetPassed.closest('div.card').find('.actionMessage'); return String(s == null ? '' : s)
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]); .replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
type = type || 'info'; .replace(/>/g, '&gt;')
callback = callback || function(){}; .replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
if($target.html() === message) return;
if($target.html()){
$target.slideUp('fast', function(){
$target.html('')
$target.removeClass (function(index, className){
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
});
if(message) return actionMessage(message, $target, type, callback);
$target.hide()
})
}else{
if(type) $target.addClass('bg-' + type);
if(!message.includes('<button')) message += `
<button class="action-close btn btn-sm btn-outline-dark float-end">
<i class="fa-solid fa-xmark"></i>
</button>
`
$target.html(message).slideDown('fast');
}
setTimeout(callback,10)
}
function actionConfirm(message, $target, type, callback){
return new Promise((resolve, reject) =>{
let id = crypto.randomUUID();
message = `
<h4 class"align-middle" >
<i class="fa-solid fa-triangle-exclamation"></i>
<b>${message}</b>
<span class="float-end">
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
<i class="fa-solid fa-circle-check"></i>
Confirm
</button>
<button type="button" class="btn btn-danger confirm-${id}">
<i class="fa-solid fa-circle-stop"></i>
Cancel
</button>
</span>
</h4>
`
actionMessage(message, $target, type);
$("body").on('click', `.confirm-${id}`, function(){
actionMessage('', $target, type);
resolve(!!$(this).data('confirm'));
});
});
} }
$.fn.serializeObject = function() { $.fn.serializeObject = function() {
@@ -415,8 +538,11 @@ app.util = (function(app){
for (let {name, value} of $(this).serializeArray()) { for (let {name, value} of $(this).serializeArray()) {
console.log(name, value) console.log(name, value)
if (obj[name] === undefined) { if (obj[name] === undefined) {
if (!value if (!value
&& !$(this).parent().find(`[name="${name}"]`).attr('value') && !$(this).parent().find(`[name="${name}"]`).attr('value')
// Keep empty <textarea>s so a cleared field is submitted (and
// can reset a list, e.g. the per-host IP/header controls).
&& !$(this).filter(`textarea[name="${name}"]`).length
){ ){
continue; continue;
} }
@@ -461,26 +587,40 @@ app.util = (function(app){
return { return {
downloadFile: downloadFile, downloadFile: downloadFile,
getUrlParameter: getUrlParameter, getUrlParameter: getUrlParameter,
actionMessage: actionMessage, escapeHtml: escapeHtml,
actionConfirm,
} }
})(app); })(app);
$( document ).ready(async function(){ // Reveal every .group-required-<cn> element the current user's groups entitle
// them to. Elements carrying .group-required start hidden (styles.css), so a
// user who is in no groups — or who isn't logged in — simply never sees them.
app.auth.applyGroupVisibility = function(user){
var groups = app.auth.groupCNs(user);
if(!groups.length) return;
// Show content if the user has the correct group var style = document.getElementById('group-required-rules');
for(let group of (await app.auth.asyncUser)?.memberOf || []){ if(!style){
style = document.createElement('style');
style.id = 'group-required-rules';
document.head.appendChild(style);
}
for(var group of groups){
try{ try{
group = group.split(',ou=groups')[0].replace('cn=', ''); style.sheet.insertRule(
`.group-required-${CSS.escape(group)} { display: revert !important; }`,
const sheet = document.styleSheets[0]; style.sheet.cssRules.length
const selector = `.group-required-${group}`; );
const cssText = `${selector} { display: revert !important; }`;
sheet.insertRule(cssText, sheet.cssRules.length);
}catch(error){ }catch(error){
// A group whose CN isn't a usable CSS identifier just gates nothing.
} }
} }
};
$( document ).ready(async function(){
// Show content the user's groups entitle them to.
app.auth.applyGroupVisibility(await app.auth.loadUser());
$('div.row').fadeIn('slow'); //show the page $('div.row').fadeIn('slow'); //show the page
@@ -502,9 +642,9 @@ $( document ).ready(async function(){
$(this).closest('.card').slideUp('fast'); $(this).closest('.card').slideUp('fast');
}); });
$('.actionMessage').on('click', 'button.action-close', function(event){ // action-close click handling is wired by @simpleworkjs/frontend's
app.util.actionMessage(null, $(this)); // app.messages.js (delegated on document, so it also covers messages
}); // rendered after this ready handler runs).
setInterval(()=>{ setInterval(()=>{
$('.momentFromNow').each((idx, el)=>{ $('.momentFromNow').each((idx, el)=>{
@@ -535,11 +675,11 @@ function formAJAX(btn){
var method = ($form.attr('method') || 'post').toLowerCase(); var method = ($form.attr('method') || 'post').toLowerCase();
if($form.validate && !$form.validate()){ if($form.validate && !$form.validate()){
app.util.actionMessage('Please fix the form errors.', $form, 'danger') app.messages.action('Please fix the form errors.', $form, 'danger')
return false; return false;
} }
app.util.actionMessage( app.messages.action(
`<div class="spinner-border" role="status"> `<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div>`, </div>`,
@@ -548,7 +688,7 @@ function formAJAX(btn){
); );
app.api[method]($form.attr('action'), formData, function(error, data){ app.api[method]($form.attr('action'), formData, function(error, data){
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
$form.validateClear(); $form.validateClear();
if(!error){ if(!error){
$form.trigger("reset"); $form.trigger("reset");
@@ -556,7 +696,7 @@ function formAJAX(btn){
}else{ }else{
console.log('formAJAX res error', error, data) console.log('formAJAX res error', error, data)
if(data && data.name === 'ObjectValidateError'){ if(data && data.name === 'ObjectValidateError'){
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
} }
if(data && data.keys){ if(data && data.keys){
console.log('form key errors', data.keys) console.log('form key errors', data.keys)
@@ -567,4 +707,3 @@ function formAJAX(btn){
} }
}); });
} }
-133
View File
@@ -1,133 +0,0 @@
( function( $ ) {
var settings = {
rule: {
eq: function(value, options){
var compare = $('[name=' + options + ']').val();
if ( value != compare ) {
return "Miss-match";
}
}
},
};
$.fn.validate = function(event) {
// let thisSettings = $.extend(true, settings, settingsObj);
let hasErrors = false;
if(this.is('[validate]')) return this.validateField(event);
if(!this.attr('isValid')){
console.log('adding reset event')
this.on('reset', function(){
$(this).attr('isValid', false);
$(this).validateClear();
})
}
this.find('[validate]').each(function(){
if(!$(this).validateField()) hasErrors = true;
});
this.attr('isValid', !hasErrors);
if(hasErrors && event) event.preventDefault();
return !hasErrors;
};
$.fn.validateClear = function(){
$(this).find('input').each(function(){
$(this).removeClass('is-invalid');
$(this).removeClass('is-valid');
})
}
$.fn.validateField = function(){
var attr = this.attr('validate').split(':'); //array of params
var rule = attr[0];
var options = attr[1];
var value = this.val(); //link to input value
var message;
if(this.prop('disabled')) return true;
//checks if field is required, and length
if(!isNaN(options) && value.length < options){
message = `Must be ${options} characters`;
}
//checks if empty to stop processing
if(!isNaN(options) && value.length === 0) {
}else if(rule in settings.rule){
let message = settings.rule[rule].apply(this, [value, options]);
}
this.validateMessage(message)
return !message;
}
$.fn.validateMessage = function(message){
if(message && message !== true){
this.closest('.form-group').find('b.invalid-feedback').html(message);
this.addClass('is-invalid');
}else{
this.removeClass('is-invalid');
this.addClass('is-valid');
}
return this;
};
jQuery.extend({
validateSettings: function( settingsObj ) {
$.extend( true, settings, settingsObj );
},
validateInit: function( ettingsObj ) {
$( '[action]' ).on( 'submit', function ( event, settingsObj ){
$( this ).validate( settingsObj, event );
});
}
});
}( jQuery ));
$.validateSettings({
rule:{
ip: function( value ) {
value = value.split( '.' );
if ( value.length != 4 ) {
return "Malformed IP";
}
$.each( value, function( key, value ) {
if( value > 255 || value < 0 ) {
return "Malformed IP";
}
});
},
host: function( value ) {
var reg = /^(?=.{1,255}$)[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?(?:\.[0-9A-Za-z](?:(?:[0-9A-Za-z]|-){0,61}[0-9A-Za-z])?)*\.?$/;
if ( reg.test( value ) === false ) {
return "Invalid";
}
},
user: function( value ) {
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
if ( reg.test( value ) === false ) {
return "Invalid";
}
},
password: function( value ) {
var reg = /^(?=[^\d_].*?\d)\w(\w|[!@#$%]){1,48}/;
if ( reg.test( value ) === false ) {
return "Weak password, Try again";
}
}
}
});
+4 -1
View File
@@ -3,6 +3,7 @@ const router = require('express').Router();
const permission = require('../utils/permission'); const permission = require('../utils/permission');
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource'); const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
const { Group } = require('../models/group_ldap'); const { Group } = require('../models/group_ldap');
const { projectResources } = require('@simpleworkjs/directory-schema');
// Require the admin group // Require the admin group
router.use(async (req, res, next) => { router.use(async (req, res, next) => {
@@ -18,7 +19,9 @@ router.use(async (req, res, next) => {
router.get('/resources', async (req, res, next) => { router.get('/resources', async (req, res, next) => {
try { try {
const resources = await Resource.list(); const resources = await Resource.list();
res.json({ results: resources }); // Even admins never receive secret metadata (e.g. client_secret_hash) over
// the wire; projectResources strips it unconditionally.
res.json({ results: projectResources(resources, { fullMetadata: true }) });
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
-36
View File
@@ -1,36 +0,0 @@
'use strict';
const router = require('express').Router();
const { Resource, ResourceGroup } = require('../models/resource');
// GET /api/discovery/me
// Returns the list of resources the current user has access to.
router.get('/me', async (req, res, next) => {
try {
const userGroups = req.user.groups || []; // array of LDAP group CNs
const accessibleResourceIds = new Set();
if (req.user.isMachine) {
// Machines only have access to themselves by default
accessibleResourceIds.add(req.resourceId);
} else {
// End users get access via groups
const allGroups = await ResourceGroup.list();
for (const rg of allGroups) {
if (userGroups.includes(rg.groupCn)) {
accessibleResourceIds.add(rg.resourceId);
}
}
}
// Fetch all resources and filter
const allResources = await Resource.list();
const accessible = allResources.filter(r => accessibleResourceIds.has(r.id) || r.metadata?.isPublic);
res.json({ results: accessible });
} catch (err) {
next(err);
}
});
module.exports = router;
+2 -2
View File
@@ -203,7 +203,7 @@ router.post('/impersonate/:uid', middleware.auth, async function(req, res, next)
const target = await User.get(req.params.uid); const target = await User.get(req.params.uid);
// Clean up any existing impersonation for this target // Clean up any existing impersonation for this target
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid }); const existing = await ImpersonationToken.list({ where: { target_uid: target.uid } });
for (const old of existing) { for (const old of existing) {
if (old.is_valid && !old.isExpired) { if (old.is_valid && !old.isExpired) {
try { await target.removeTempPassword(old.temp_hash); } catch(_) {} try { await target.removeTempPassword(old.temp_hash); } catch(_) {}
@@ -237,7 +237,7 @@ router.delete('/impersonate/:uid', middleware.auth, async function(req, res, nex
await permission.byGroup(req.user, ['app_sso_admin']); await permission.byGroup(req.user, ['app_sso_admin']);
const target = await User.get(req.params.uid); const target = await User.get(req.params.uid);
const existing = await ImpersonationToken.listDetail({ target_uid: target.uid }); const existing = await ImpersonationToken.list({ where: { target_uid: target.uid } });
let revoked = 0; let revoked = 0;
for (const token of existing) { for (const token of existing) {
+78 -3
View File
@@ -1,4 +1,79 @@
const autoRouter = require('./autoRouter'); 'use strict';
const { Resource } = require('../models/resource');
module.exports = autoRouter(Resource); // Public directory discovery API. Mounted at /api/discovery (app.js, before
// the 404 catcher). Every response uses the `{ results }` envelope and the
// security projection from @simpleworkjs/directory-schema, so secrets (e.g. an
// OAuth client's client_secret_hash) never leave the server and non-admins only
// see the public metadata allowlist.
//
// This replaces the autoRouter mount (which returned bare arrays — the shape
// jump-host's `data.results || []` silently collapsed to `[]`, so no user could
// bridge) and absorbs the dead /me handler that used to live in
// routes/api_discovery.js (mounted after the 404, so unreachable).
const router = require('express').Router();
const { Resource, ResourceGroup } = require('../models/resource');
const {
envelope,
projectResource,
projectResources,
isDirectoryAdmin,
} = require('@simpleworkjs/directory-schema');
// GET /api/discovery/resources[?kind=&group=&parent=]
router.get('/resources', async (req, res, next) => {
try {
const resources = await Resource.search(req.query);
res.json(envelope(projectResources(resources, { fullMetadata: isDirectoryAdmin(req.user) })));
} catch (err) { next(err); }
});
// GET /api/discovery/resources/:slug
router.get('/resources/:slug', async (req, res, next) => {
try {
const resource = await Resource.getBySlug(req.params.slug);
// parents/children are edges (no secrets); project only the resource body.
const projected = projectResource(resource, { fullMetadata: isDirectoryAdmin(req.user) });
projected.parents = resource.parents;
projected.children = resource.children;
res.json(envelope(projected));
} catch (err) { next(err); }
});
// GET /api/discovery/graph
router.get('/graph', async (req, res, next) => {
try {
const graph = await Resource.getGraph();
res.json(envelope({
resources: projectResources(graph.resources, { fullMetadata: isDirectoryAdmin(req.user) }),
edges: graph.edges,
}));
} catch (err) { next(err); }
});
// GET /api/discovery/me
// Returns the resources the current caller can reach. Machines see only their
// own resource; humans get the union of their LDAP groups' resources plus
// anything flagged isPublic. Uses req.user.groups (populated by the auth
// middleware for session/PAT callers) rather than re-querying LDAP by DN, so it
// works for every auth transport without assuming a .dn is present.
router.get('/me', async (req, res, next) => {
try {
let accessible;
if (req.user && req.user.isMachine) {
accessible = await Resource.list({ where: { id: req.resourceId } });
} else {
const userGroups = (req.user && req.user.groups) || [];
const ids = new Set();
if (userGroups.length) {
const rgs = await ResourceGroup.list({ where: { groupCn: { in: userGroups } } });
for (const rg of rgs) ids.add(rg.resourceId);
}
const all = await Resource.list();
accessible = all.filter(r => ids.has(r.id) || (r.metadata && r.metadata.isPublic));
}
res.json(envelope(projectResources(accessible, { fullMetadata: isDirectoryAdmin(req.user) })));
} catch (err) { next(err); }
});
module.exports = router;
+1
View File
@@ -33,6 +33,7 @@ const DOCS = {
accounts: {title: 'Accounts, Groups & Managers', file: path.join(__dirname, '../../docs/concepts-accounts.md')}, accounts: {title: 'Accounts, Groups & Managers', file: path.join(__dirname, '../../docs/concepts-accounts.md')},
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')}, 'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')}, 'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')}, overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')}, changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
+7 -14
View File
@@ -10,6 +10,7 @@ const {InviteToken, PasswordResetToken} = require('./../models/token');
const {Tos} = require('../models/tos'); const {Tos} = require('../models/tos');
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info'); const buildInfo = require('../utils/build_info');
const { mountStaticModules } = require('@simpleworkjs/app-stack');
const values ={ const values ={
title: conf.environment !== 'production' ? `dev` : '', title: conf.environment !== 'production' ? `dev` : '',
@@ -20,24 +21,16 @@ const values ={
} }
// List of front end node modules to be served // List of front end node modules to be served
const frontEndModules = ['bootstrap', 'mustache', 'jquery', '@fortawesome',
'moment', '@popper', 'jq-repeat',
];
// Server front end modules
// https://stackoverflow.com/a/55700773/3140931
// Vendor libraries only change when package versions are bumped (a rebuild), // Vendor libraries only change when package versions are bumped (a rebuild),
// so they're safe to cache aggressively; ETag/Last-Modified (on by default) // so they're safe to cache aggressively; ETag/Last-Modified (on by default)
// still cover that rare case with a cheap 304 instead of a stale asset. // still cover that rare case with a cheap 304 instead of a stale asset. The
frontEndModules.forEach(dep => { // app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
router.use(`/static-modules/${dep}`, express.static(path.join(__dirname, `../node_modules/${dep}`), {maxAge: '7d'})) // every deploy and isn't cache-busted/fingerprinted.
mountStaticModules(router, {
root: path.join(__dirname, '..'),
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat', '@simpleworkjs/frontend'],
}); });
// Have express server static content( images, CSS, browser JS) from the public
// local folder. Shorter maxAge than /static-modules since this is the app's
// own JS/CSS, which changes on every deploy and isn't cache-busted/fingerprinted.
router.use('/static', express.static(path.join(__dirname, '../public'), {maxAge: '1h'}))
// Public health endpoint for container/orchestration healthchecks. // Public health endpoint for container/orchestration healthchecks.
// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated. // Mounted at / (no auth) in app.js, so this is intentionally unauthenticated.
router.get('/health', function(req, res) { router.get('/health', function(req, res) {
+127
View File
@@ -0,0 +1,127 @@
'use strict';
const router = require('express').Router();
const { OAuthClient } = require('../models/oauth_client');
const permission = require('../utils/permission');
const ADMIN_GROUP = 'app_sso_oauth_admin';
router.get('/', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
return res.json({ results: await OAuthClient.listDetail() });
} catch(error) {
next(error);
}
});
router.post('/', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
req.body.created_by = req.user.uid;
// Parse redirect_uris if sent as newline-separated string from the form
if (typeof req.body.redirect_uris === 'string') {
req.body.redirect_uris = req.body.redirect_uris.split('\n').map(s => s.trim()).filter(Boolean);
}
// Parse scopes if sent as space-separated string
if (typeof req.body.scopes === 'string') {
req.body.scopes = req.body.scopes.split(' ').map(s => s.trim()).filter(Boolean);
}
// Parse allowed_groups if sent as newline-separated string
if (typeof req.body.allowed_groups === 'string') {
req.body.allowed_groups = req.body.allowed_groups.split('\n').map(s => s.trim()).filter(Boolean);
}
// jQuery serializeObject sends nested fields as "token_lifetime[access_token]"
if (req.body['token_lifetime[access_token]'] || req.body['token_lifetime[refresh_token]']) {
req.body.token_lifetime = {
access_token: Number(req.body['token_lifetime[access_token]']) || 3600,
refresh_token: Number(req.body['token_lifetime[refresh_token]']) || 2592000,
};
delete req.body['token_lifetime[access_token]'];
delete req.body['token_lifetime[refresh_token]'];
}
const client = await OAuthClient.add(req.body);
const result = client.toJSON ? client.toJSON() : { ...client };
result.client_id = client.client_id || client.id;
return res.json({
results: result,
client_secret: client._raw_secret,
message: `OAuth client '${client.name}' created. Save the client secret — it will not be shown again.`,
});
} catch(error) {
next(error);
}
});
router.get('/:client_id', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
return res.json({ results: await OAuthClient.get(req.params.client_id) });
} catch(error) {
next(error);
}
});
router.put('/:client_id', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const client = await OAuthClient.get(req.params.client_id);
if (typeof req.body.redirect_uris === 'string') {
req.body.redirect_uris = req.body.redirect_uris.split('\n').map(s => s.trim()).filter(Boolean);
}
if (typeof req.body.scopes === 'string') {
req.body.scopes = req.body.scopes.split(' ').map(s => s.trim()).filter(Boolean);
}
if (typeof req.body.allowed_groups === 'string') {
req.body.allowed_groups = req.body.allowed_groups.split('\n').map(s => s.trim()).filter(Boolean);
}
return res.json({
results: await client.update(req.body),
message: `OAuth client '${client.name}' updated.`,
});
} catch(error) {
next(error);
}
});
router.delete('/:client_id', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const client = await OAuthClient.get(req.params.client_id);
await client.remove();
return res.json({
client_id: req.params.client_id,
message: `OAuth client '${client.name}' deleted.`,
});
} catch(error) {
next(error);
}
});
router.post('/:client_id/rotate', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const client = await OAuthClient.get(req.params.client_id);
const new_secret = await client.rotateSecret();
return res.json({
client_secret: new_secret,
message: `Client secret rotated for '${client.name}'. Save it — it will not be shown again.`,
});
} catch(error) {
next(error);
}
});
module.exports = router;
+10 -4
View File
@@ -23,8 +23,10 @@ router.get('/', async function(req, res, next){
router.get('/:name', async function(req, res, next){ router.get('/:name', async function(req, res, next){
try{ try{
// ORM models: list() on the redis adapter always returns full rows;
// detail is handled by serialization (isPrivate fields are excluded).
return res.json({ return res.json({
results: await tokens[req.params.name][req.query.detail ? "listDetail" : "list"]() results: await tokens[req.params.name].list()
}); });
}catch(error){ }catch(error){
next(error); next(error);
@@ -34,9 +36,13 @@ router.get('/:name', async function(req, res, next){
router.get('/:name/:token', async function(req, res, next){ router.get('/:name/:token', async function(req, res, next){
try{ try{
return res.json({ const result = await tokens[req.params.name].get(req.params.token);
results: await tokens[req.params.name].get(req.params.token) if (!result) {
}); const error = new Error('Token not found');
error.status = 404;
throw error;
}
return res.json({ results: result });
}catch(error){ }catch(error){
next(error); next(error);
} }
+11 -1
View File
@@ -74,7 +74,17 @@ router.delete('/:uid', async function(req, res, next){
router.get('/me', async function(req, res, next){ router.get('/me', async function(req, res, next){
try{ try{
return res.json(await User.get({uid: req.user.uid})); const user = JSON.parse(JSON.stringify(await User.get({uid: req.user.uid})));
// The shared client framework gates the UI on a single effective-rights
// flag (the OIDC-client apps send the same key). Here "admin" means
// membership in app_sso_admin; group-level gating still reads memberOf.
const groups = (user.memberOf || []).map(function(dn){
return String(dn).split(',')[0].replace(/^cn=/i, '');
});
user.isAdmin = groups.includes('app_sso_admin');
return res.json(user);
}catch(error){ }catch(error){
next(error); next(error);
} }
+111
View File
@@ -0,0 +1,111 @@
'use strict';
// Directory discovery API — security + contract regression coverage.
//
// These tests run under the jest + docker harness (redis + the test seed).
// They lock in the two fixes from the @simpleworkjs/directory-schema release:
// 1. /api/discovery/* returns the { results } envelope (not a bare array —
// the drift that made jump-host's `data.results || []` collapse to []).
// 2. No response path leaks secret metadata (e.g. an OAuth client's
// client_secret_hash), regardless of caller.
//
// The core assertions hold for any authenticated caller. The admin-projection
// assertion (fullMetadata for directory admins) additionally requires the `test`
// seed user to be a member of app_sso_directory_admin — see setup.js.
const { login, request, app } = require('./setup');
let token;
beforeAll(async () => {
token = await login();
});
function assertNoSecrets(results, path) {
for (const r of results || []) {
// toBeUndefined() in this jest version takes no message arg, so assert
// manually and throw with context — this also surfaces the leaked value
// if the projection ever regresses.
const secretHash = r.metadata && r.metadata.client_secret_hash;
if (secretHash !== undefined) {
throw new Error(
`client_secret_hash leaked from ${path} on ${r.slug || r.id} (value: ${JSON.stringify(secretHash)})`
);
}
if (r.metadata) {
for (const k of Object.keys(r.metadata)) {
if (/secret|password|privatekey/i.test(k)) {
throw new Error(`secret-ish key "${k}" leaked from ${path} on ${r.slug || r.id}`);
}
}
}
}
}
describe('Discovery — envelope + security', () => {
test('GET /api/discovery/resources returns 200 with { results } (not a bare array)', async () => {
const res = await request(app).get('/api/discovery/resources').set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
expect(Array.isArray(res.body)).toBe(false); // never a bare array
});
test('GET /api/discovery/resources never leaks client_secret_hash', async () => {
const res = await request(app).get('/api/discovery/resources').set('auth-token', token);
assertNoSecrets(res.body.results, '/resources');
});
test('GET /api/discovery/resources?group= returns 200 (regression: was 404)', async () => {
const res = await request(app)
.get('/api/discovery/resources?group=host_web01_access')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
});
test('GET /api/discovery/graph returns { results: { resources, edges } } and strips secrets', async () => {
const res = await request(app).get('/api/discovery/graph').set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.results).toBeDefined();
expect(Array.isArray(res.body.results.resources)).toBe(true);
assertNoSecrets(res.body.results.resources, '/graph');
});
test('GET /api/discovery/me returns 200 with { results } and strips secrets', async () => {
const res = await request(app).get('/api/discovery/me').set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
assertNoSecrets(res.body.results, '/me');
});
test('GET /api/discovery/resources/:slug returns 200 + { results } for a known slug', async () => {
// Seed-dependent: pick the first slug from the list, then fetch it.
const list = await request(app).get('/api/discovery/resources').set('auth-token', token);
const slug = list.body.results[0] && list.body.results[0].slug;
if (!slug) return; // empty seed — skip rather than fail
const res = await request(app)
.get(`/api/discovery/resources/${encodeURIComponent(slug)}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.results).toBeDefined();
expect(res.body.results.slug).toBe(slug);
assertNoSecrets([res.body.results], '/resources/:slug');
});
});
describe('Discovery — admin projection (requires test user in app_sso_directory_admin)', () => {
// If the seed `test` user is a directory admin, /resources should keep
// admin-only (non-secret) metadata like redirect_uris/token_lifetime for
// them. If not, this assertion is skipped — the no-secrets assertion above
// already covers the security guarantee for every caller.
test('admin callers keep token_lifetime / redirect_uris (non-secret admin keys)', async () => {
const res = await request(app).get('/api/discovery/resources?kind=oauth').set('auth-token', token);
const oauth = (res.body.results || []).find(r => r.kind === 'oauth');
if (!oauth) return; // no oauth resource seeded
// Only meaningful if the caller is an admin; non-admins correctly get
// the public allowlist (no redirect_uris). We assert the absence of
// secrets regardless, and skip the positive admin check without a known
// admin seed.
expect(oauth.metadata && oauth.metadata.client_secret_hash).toBeUndefined();
});
});
+2 -1
View File
@@ -5,7 +5,8 @@
const { createClient } = require('redis'); const { createClient } = require('redis');
module.exports = async function() { module.exports = async function() {
const client = createClient(); const redisUrl = process.env.REDIS_URL || undefined;
const client = createClient(redisUrl ? { url: redisUrl } : {});
await client.connect(); await client.connect();
const keys = await client.keys('sso_manager_test_*'); const keys = await client.keys('sso_manager_test_*');
+59
View File
@@ -43,6 +43,65 @@ afterAll(async () => {
} }
}); });
describe('OAuth client management API — /api/oauth/client', () => {
// Regression: the ORM Model.toJSON() strips non-schema fields, so the
// mapped client_id/scopes/etc. used to vanish from GET responses —
// client_id came back undefined and the theta-env bootstrap's rotate
// crashed with a 500. GET must expose client_id (and never the secret hash).
test('GET / list exposes client_id and hides client_secret_hash', async () => {
const res = await request(app)
.get('/api/oauth/client/')
.set('auth-token', token);
expect(res.status).toBe(200);
const mine = res.body.results.find((c) => c.client_id === clientId);
expect(mine).toBeDefined();
expect(mine.client_id).toBe(clientId);
expect(mine).toHaveProperty('scopes');
expect(mine).not.toHaveProperty('client_secret_hash');
});
test('GET /:id exposes client_id', async () => {
const res = await request(app)
.get(`/api/oauth/client/${clientId}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.results.client_id).toBe(clientId);
expect(res.body.results).not.toHaveProperty('client_secret_hash');
});
test('list then rotate a client by its returned client_id (the bootstrap path)', async () => {
// Reproduces exactly what the theta-env bootstrap does: create, list,
// find by name, rotate by the client_id from the list response. Uses a
// throwaway client so the shared flow client's secret is untouched.
const created = await request(app)
.post('/api/oauth/client/')
.set('auth-token', token)
.send({ name: 'rotate-regression', redirect_uris: REDIRECT_URI });
expect(created.status).toBe(200);
const list = await request(app).get('/api/oauth/client/').set('auth-token', token);
const found = list.body.results.find((c) => c.name === 'rotate-regression');
expect(found).toBeDefined();
expect(found.client_id).toBeTruthy(); // was undefined before the fix
const rotated = await request(app)
.post(`/api/oauth/client/${found.client_id}/rotate`)
.set('auth-token', token);
expect(rotated.status).toBe(200);
expect(rotated.body.client_secret).toBeTruthy();
await request(app).delete(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
});
test('GET /:id unknown id returns 404, not 500', async () => {
const res = await request(app)
.get('/api/oauth/client/00000000-0000-0000-0000-000000000000')
.set('auth-token', token);
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
});
});
describe('OIDC Discovery', () => { describe('OIDC Discovery', () => {
test('GET /.well-known/openid-configuration returns required fields', async () => { test('GET /.well-known/openid-configuration returns required fields', async () => {
const res = await request(app).get('/.well-known/openid-configuration'); const res = await request(app).get('/.well-known/openid-configuration');
+13
View File
@@ -3,6 +3,19 @@
const crypto = require('crypto'); const crypto = require('crypto');
const request = require('supertest'); const request = require('supertest');
const app = require('../app'); const app = require('../app');
const { initORM } = require('../models');
beforeAll(async () => {
await initORM();
const { Token } = require('../models/token');
try {
if (Token.orm) {
await Token.orm.adapter(Token).Table.redisClient.flushDb();
}
} catch (e) {
console.warn('Could not flush Redis:', e.message);
}
});
const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' }; const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' };
+12 -25
View File
@@ -1,29 +1,16 @@
'use strict'; 'use strict';
const fs = require('fs'); // Unified build-info shape ({ buildVersion, buildHash, buildYear }) via the
// shared @simpleworkjs/app-stack. The baked commit file lives at nodejs/.build_commit
// (../ from here in utils/), matching the Dockerfile.openldap gitinfo stage;
// cwd is utils/ for the bare-metal git fallback.
const path = require('path'); const path = require('path');
const { execSync } = require('child_process'); const { createBuildInfo } = require('@simpleworkjs/app-stack');
const { version: buildVersion } = require('../package.json'); const { version } = require('../package.json');
// Docker builds bake the commit hash into ../.build_commit (see the gitinfo module.exports = createBuildInfo({
// stage in Dockerfile.openldap) -- the final image has no git binary and no version,
// .git directory, so `git rev-parse` below always fails there. Bare-metal/dev buildCommitPath: path.join(__dirname, '../.build_commit'),
// runs have no baked file, so they fall back to asking git directly. cwd: __dirname,
function readBuildHash() { });
try {
const baked = fs.readFileSync(path.join(__dirname, '../.build_commit'), 'utf8').trim();
if (baked) return baked;
} catch (_) {}
try {
return execSync('git rev-parse --short HEAD', { cwd: __dirname }).toString().trim();
} catch (_) {
return 'unknown';
}
}
module.exports = {
buildVersion,
buildHash: readBuildHash(),
buildYear: new Date().getFullYear(),
};
+46
View File
@@ -0,0 +1,46 @@
'use strict';
// Per-app values for the shared UI shell (views/top.ejs + views/bottom.ejs).
//
// Those two partials are byte-identical across sso-manager-node, proxy and
// jump-host — everything that differs between the apps lives here and is
// exposed to every render as `ui` via app.locals (see app.js). Keep the key set
// in sync across the three apps; a missing key is a render-time ReferenceError,
// not a silent fallback.
const conf = require('@simpleworkjs/conf');
module.exports = {
// --- footer -------------------------------------------------------------
repoUrl: 'https://github.com/theta42/sso-manager-node',
licenseUrl: 'https://github.com/theta42/sso-manager-node/blob/master/LICENSE',
// In-app docs route (routes/docs.js). Apps without one point at the
// published docs site and set docsExternal.
docsUrl: '/docs',
docsExternal: false,
// Only sso-manager-node serves a Terms of Service page; null hides the link.
tosUrl: '/tos',
// --- header / nav -------------------------------------------------------
faviconUrl: conf.logo,
// Where the current-user chip links. null renders it as a plain span (for
// apps with no profile page).
profileUrl: '/profile',
// Where "Log Out" lands.
logoutRedirect: '/',
// Admin-only "a newer release is available" banner, backed by
// GET /api/update-check. Apps without that endpoint set false.
updateCheck: true,
updateLabel: 'SSO Manager',
// Nav items, in order. `groups` is an OR-list of group CNs that may see the
// item; an empty list means "always visible". Gating is done client-side by
// app-base.js, which reveals .group-required-<cn> for each group the user is
// in (plus the synthetic `admin` group when user/me reports isAdmin).
nav: [
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin']},
{href: '/groups', icon: 'fa-solid fa-users-viewfinder', label: 'Groups', groups: ['app_sso_admin']},
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin']},
{href: '/executive', icon: 'fa-solid fa-gauge-high', label: 'Executive', groups: ['app_sso_admin']},
],
};
+10 -5
View File
@@ -1,5 +1,8 @@
</div><!-- end spa-shell --> </div><!-- end spa-shell -->
<!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed via
app.locals in app.js). Edit all three copies together. -->
<footer class="py-2 bg-dark text-light mt-4"> <footer class="py-2 bg-dark text-light mt-4">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2"> <div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2">
<span class="d-flex align-items-center gap-2"> <span class="d-flex align-items-center gap-2">
@@ -7,19 +10,21 @@
<img width="64" src="/static/img/theta42.svg"/> <img width="64" src="/static/img/theta42.svg"/>
</a> </a>
&copy; <%- buildYear %> theta42 &middot; &copy; <%- buildYear %> theta42 &middot;
<a href="https://github.com/theta42/sso-manager-node/blob/master/LICENSE" target="_blank" class="text-light">MIT License</a> <a href="<%- ui.licenseUrl %>" target="_blank" class="text-light">MIT License</a>
</span> </span>
<span class="d-flex align-items-center gap-3"> <span class="d-flex align-items-center gap-3">
<a href="/docs" class="text-light text-decoration-none"> <a href="<%- ui.docsUrl %>"<%- ui.docsExternal ? ' target="_blank"' : '' %> class="text-light text-decoration-none">
<i class="fa-solid fa-book"></i> Docs <i class="fa-solid fa-book"></i> Docs
</a> </a>
<a href="https://github.com/theta42/sso-manager-node" target="_blank" class="text-light text-decoration-none"> <a href="<%- ui.repoUrl %>" target="_blank" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub <i class="fa-brands fa-github"></i> GitHub
</a> </a>
<a href="/tos" class="text-light text-decoration-none">Terms of Service</a> <% if(ui.tosUrl){ %>
<a href="<%- ui.tosUrl %>" class="text-light text-decoration-none">Terms of Service</a>
<% } %>
</span> </span>
<span>v<%- buildVersion %> (<%- buildHash %>)</span> <span>v<%- buildVersion %> (<%- buildHash %>)</span>
</div> </div>
</footer> </footer>
</body> </body>
</html> </html>
+5 -2
View File
@@ -29,6 +29,7 @@
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="p-3 pb-0 text-muted small border-bottom"> <div class="p-3 pb-0 text-muted small border-bottom">
<i class="fa-solid fa-circle-info"></i> Manage infrastructure, services, and their relationships. <i class="fa-solid fa-circle-info"></i> Manage infrastructure, services, and their relationships.
<a href="/docs/directory" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="card-body table table-striped mb-0"> <table class="card-body table table-striped mb-0">
@@ -571,6 +572,8 @@
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')')); $target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
} else if (kind === 'service' && (r.kind === 'host' || r.kind === 'service')) { } else if (kind === 'service' && (r.kind === 'host' || r.kind === 'service')) {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')')); $target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
} else if (kind === 'oauth' && r.kind === 'service') {
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
} }
}); });
if (selectedId) $target.val(selectedId); if (selectedId) $target.val(selectedId);
@@ -711,7 +714,7 @@
await loadResources(); await loadResources();
if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) { if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) {
app.util.alert('OAuth Secret', 'Save this client secret, it will not be shown again: <br><br><code>' + res.results._raw_secret + '</code>', 'success'); app.modal.open({title: 'OAuth Secret', bodyHtml: 'Save this client secret, it will not be shown again: <br><br><code>' + res.results._raw_secret + '</code>'});
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -726,7 +729,7 @@
try { try {
const res = await app.api.post(`directory-admin/resources/${id}/rotate-secret`); const res = await app.api.post(`directory-admin/resources/${id}/rotate-secret`);
app.util.alert('Secret Rotated', 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>', 'success'); app.modal.open({title: 'Secret Rotated', bodyHtml: 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>'});
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert(err.message || 'Failed to rotate secret'); alert(err.message || 'Failed to rotate secret');
+1 -1
View File
@@ -129,7 +129,7 @@
// trying the form out — make it a deliberate, confirmed action. // trying the form out — make it a deliberate, confirmed action.
if (filterType === 'all' || filterType === 'all_active') { if (filterType === 'all' || filterType === 'all_active') {
const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users'; const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users';
const confirmed = await app.util.actionConfirm(`Send this notification to ${label}?`, $compose, 'warning'); const confirmed = await app.messages.confirm(`Send this notification to ${label}?`, $compose, 'warning');
if (!confirmed) return; if (!confirmed) return;
} }
+9 -9
View File
@@ -35,7 +35,7 @@
async function addedUser(message, group, user, $form){ async function addedUser(message, group, user, $form){
let data = await app.group.get(group); let data = await app.group.get(group);
$.scope.groupCard.update('cn', group, processGroup(data.results)); $.scope.groupCard.update('cn', group, processGroup(data.results));
app.util.actionMessage(message, $("#group-card-"+group), 'success'); app.messages.action(message, $("#group-card-"+group), 'success');
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show'); $('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
setTimeout(function(group){ setTimeout(function(group){
$("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0); $("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0);
@@ -73,44 +73,44 @@
async function removeMember(groupCN, uid, btn) { async function removeMember(groupCN, uid, btn) {
const $item = $(btn).closest('li'); const $item = $(btn).closest('li');
$item.addClass('list-group-item-warning'); $item.addClass('list-group-item-warning');
const confirmed = await app.util.actionConfirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning'); const confirmed = await app.messages.confirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; } if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
try { try {
const data = await app.api.delete(`group/${groupCN}/${uid}`); const data = await app.api.delete(`group/${groupCN}/${uid}`);
const groupData = await app.group.get(groupCN); const groupData = await app.group.get(groupCN);
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results)); $.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
app.util.actionMessage(data.message, $('#group-card-' + groupCN), 'success'); app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
} catch(e) { } catch(e) {
$item.removeClass('list-group-item-warning'); $item.removeClass('list-group-item-warning');
app.util.actionMessage(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger'); app.messages.action(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
} }
} }
async function removeOwner(groupCN, uid, btn) { async function removeOwner(groupCN, uid, btn) {
const $item = $(btn).closest('li'); const $item = $(btn).closest('li');
$item.addClass('list-group-item-warning'); $item.addClass('list-group-item-warning');
const confirmed = await app.util.actionConfirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning'); const confirmed = await app.messages.confirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; } if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
try { try {
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`); const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
const groupData = await app.group.get(groupCN); const groupData = await app.group.get(groupCN);
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results)); $.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
app.util.actionMessage(data.message, $('#group-card-' + groupCN), 'success'); app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
} catch(e) { } catch(e) {
$item.removeClass('list-group-item-warning'); $item.removeClass('list-group-item-warning');
app.util.actionMessage(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger'); app.messages.action(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
} }
} }
async function deleteGroup(cn, btn) { async function deleteGroup(cn, btn) {
const $card = $(btn).closest('.card'); const $card = $(btn).closest('.card');
const confirmed = await app.util.actionConfirm(`Delete group "${cn}"?`, $card, 'danger'); const confirmed = await app.messages.confirm(`Delete group "${cn}"?`, $card, 'danger');
if (!confirmed) return; if (!confirmed) return;
try { try {
await app.api.delete(`group/${cn}`); await app.api.delete(`group/${cn}`);
$.scope.groupCard.remove('cn', cn); $.scope.groupCard.remove('cn', cn);
} catch(e) { } catch(e) {
app.util.actionMessage(e.message || 'Failed to delete group', $card, 'danger'); app.messages.action(e.message || 'Failed to delete group', $card, 'danger');
} }
} }
+1 -1
View File
@@ -79,7 +79,7 @@ function startImpersonate(uid){
$('#impersonateStopBtn').off('click').on('click', function(){ $('#impersonateStopBtn').off('click').on('click', function(){
app.impersonate.revoke(data.uid, function(err){ app.impersonate.revoke(data.uid, function(err){
$('#impersonateModal').modal('hide'); $('#impersonateModal').modal('hide');
if(!err) app.util.actionMessage('Impersonation ended for ' + data.uid, $('body'), 'success'); if(!err) app.messages.action('Impersonation ended for ' + data.uid, $('body'), 'success');
}); });
}); });
+1 -1
View File
@@ -1,7 +1,7 @@
<%- include('top') %> <%- include('top') %>
<script type="text/javascript"> <script type="text/javascript">
function tableAJAX(message){ function tableAJAX(message){
app.util.actionMessage(message); app.messages.action(message);
} }
$(document).ready(function(){ $(document).ready(function(){
+1 -1
View File
@@ -131,6 +131,6 @@
}); });
function requestAccess(id) { function requestAccess(id) {
app.util.alert('Access Request', 'This feature is coming soon!', 'info'); app.modal.open({title: 'Access Request', bodyHtml: 'This feature is coming soon!'});
} }
</script> </script>
+1 -1
View File
@@ -39,7 +39,7 @@
app.api.post('oauth/authorize', oauthParams, function(error, data){ app.api.post('oauth/authorize', oauthParams, function(error, data){
if(error){ if(error){
$btn.prop('disabled', false).html('<i class="fa-solid fa-check"></i> Allow'); $btn.prop('disabled', false).html('<i class="fa-solid fa-check"></i> Allow');
app.util.actionMessage(data.message || 'Authorization failed.', $('#authorize-card'), 'danger'); app.messages.action(data.message || 'Authorization failed.', $('#authorize-card'), 'danger');
return; return;
} }
window.location.href = data.redirect_url; window.location.href = data.redirect_url;
+13 -13
View File
@@ -27,10 +27,10 @@
async function removeFromGroup(cn, btn){ async function removeFromGroup(cn, btn){
const $row = $(btn).closest('tr'); const $row = $(btn).closest('tr');
const confirmed = await app.util.actionConfirm(`Remove ${currentUser.uid} from "${cn}"?`, $row, 'warning'); const confirmed = await app.messages.confirm(`Remove ${currentUser.uid} from "${cn}"?`, $row, 'warning');
if (!confirmed) return; if (!confirmed) return;
app.api.delete('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), function(error, data){ app.api.delete('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), function(error, data){
if(error){ app.util.actionMessage((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; } if(error){ app.messages.action((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
$.scope.mygroups.remove('cn', cn); $.scope.mygroups.remove('cn', cn);
}); });
} }
@@ -43,7 +43,7 @@
for(const cn of cns){ for(const cn of cns){
await new Promise(function(resolve){ await new Promise(function(resolve){
app.api.put('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), {}, function(error, data){ app.api.put('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), {}, function(error, data){
if(error) app.util.actionMessage((data && data.message) || `Failed to add to "${cn}"`, $card, 'danger'); if(error) app.messages.action((data && data.message) || `Failed to add to "${cn}"`, $card, 'danger');
resolve(); resolve();
}); });
}); });
@@ -64,10 +64,10 @@
async function removePersonalGroupMember(memberUid, btn){ async function removePersonalGroupMember(memberUid, btn){
const $row = $(btn).closest('tr'); const $row = $(btn).closest('tr');
const confirmed = await app.util.actionConfirm(`Remove ${memberUid} from ${currentUser.uid}'s group?`, $row, 'warning'); const confirmed = await app.messages.confirm(`Remove ${memberUid} from ${currentUser.uid}'s group?`, $row, 'warning');
if (!confirmed) return; if (!confirmed) return;
app.api.delete('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(memberUid), function(error, data){ app.api.delete('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(memberUid), function(error, data){
if(error){ app.util.actionMessage((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; } if(error){ app.messages.action((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
$.scope.personalGroupMembers.remove('uid', memberUid); $.scope.personalGroupMembers.remove('uid', memberUid);
}); });
} }
@@ -80,7 +80,7 @@
for(const uid of uids){ for(const uid of uids){
await new Promise(function(resolve){ await new Promise(function(resolve){
app.api.put('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(uid), {}, function(error, data){ app.api.put('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(uid), {}, function(error, data){
if(error) app.util.actionMessage((data && data.message) || `Failed to add "${uid}"`, $card, 'danger'); if(error) app.messages.action((data && data.message) || `Failed to add "${uid}"`, $card, 'danger');
resolve(); resolve();
}); });
}); });
@@ -184,11 +184,11 @@
async function deleteUser(uid, btn){ async function deleteUser(uid, btn){
const $card = $(btn).closest('.card'); const $card = $(btn).closest('.card');
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $card, 'warning'); const confirmed = await app.messages.confirm(`Delete user "${uid}"?`, $card, 'warning');
if (!confirmed) return; if (!confirmed) return;
app.api.delete('user/' + uid, function(error, data){ app.api.delete('user/' + uid, function(error, data){
if (error) { if (error) {
app.util.actionMessage(data.message || 'Failed to delete user', $card, 'danger'); app.messages.action(data.message || 'Failed to delete user', $card, 'danger');
return; return;
} }
window.location.href = '/users'; window.location.href = '/users';
@@ -662,21 +662,21 @@
async function revokeToken(id, name, btn){ async function revokeToken(id, name, btn){
var $card = $(btn).closest('.card'); var $card = $(btn).closest('.card');
$card.addClass('table-warning'); $card.addClass('table-warning');
var confirmed = await app.util.actionConfirm('Revoke API token "' + name + '"? It stops working immediately.', $card, 'warning'); var confirmed = await app.messages.confirm('Revoke API token "' + name + '"? It stops working immediately.', $card, 'warning');
$card.removeClass('table-warning'); $card.removeClass('table-warning');
if(!confirmed) return; if(!confirmed) return;
app.apiToken.remove({id: id}, function(error, data){ app.apiToken.remove({id: id}, function(error, data){
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; } if(error){ app.messages.action('Error: ' + data.message, $card, 'danger'); return; }
$.scope.apiTokenCard.remove('id', id); $.scope.apiTokenCard.remove('id', id);
}); });
} }
async function rotateToken(id, name, btn){ async function rotateToken(id, name, btn){
var $card = $(btn).closest('.card'); var $card = $(btn).closest('.card');
var confirmed = await app.util.actionConfirm('Rotate API token "' + name + '"? The old token stops working immediately.', $card, 'warning'); var confirmed = await app.messages.confirm('Rotate API token "' + name + '"? The old token stops working immediately.', $card, 'warning');
if(!confirmed) return; if(!confirmed) return;
app.apiToken.rotate({id: id}, function(error, data){ app.apiToken.rotate({id: id}, function(error, data){
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; } if(error){ app.messages.action('Error: ' + data.message, $card, 'danger'); return; }
showSecret(data.token); showSecret(data.token);
tableAJAX(); tableAJAX();
}); });
@@ -700,7 +700,7 @@
expires_in_days: $('#edit-expires_in_days').val(), expires_in_days: $('#edit-expires_in_days').val(),
}; };
app.apiToken.update(payload, function(error, data){ app.apiToken.update(payload, function(error, data){
if(error){ app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger'); return; } if(error){ app.messages.action((data && data.message) || 'Update failed.', $msg.parent(), 'danger'); return; }
editModal.hide(); editModal.hide();
tableAJAX(); tableAJAX();
}); });
+145 -132
View File
@@ -1,138 +1,151 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title><%- name %> <%- title %></title> <title><%- name %> <%- title %></title>
<!-- Favicon --> <!-- Shared UI shell — byte-identical across sso-manager-node, proxy and
<link rel="icon" type="image/svg+xml" href="<%- logo %>"> jump-host. Everything per-app comes from `ui` (utils/ui.js, exposed
<!-- CSS are placed here --> via app.locals in app.js). Edit all three copies together. -->
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css"> <!-- Favicon -->
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css"> <link rel="icon" type="image/svg+xml" href="<%- ui.faviconUrl %>">
<!-- CSS are placed here -->
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
<link rel='stylesheet' href='/static/css/styles.css' /> <link rel='stylesheet' href='/static/css/styles.css' />
<!-- Scripts are placed here --> <!-- Scripts are placed here -->
<script type="text/javascript" src="/socket.io/socket.io.js"></script> <script type="text/javascript" src="/socket.io/socket.io.js"></script>
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script> <script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
<!-- <script type="text/javascript" src="/static/lib/js/popper-1.16.0.min.js"></script> --> <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<!-- <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.min.js"></script> --> <script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script> <script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script> <script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script> <script type="text/javascript" src="/static-modules/moment/moment.js"></script>
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script> <script type="text/javascript" src="/static/lib/js/app-base.js"></script>
<script type="text/javascript" src='/static/lib/js/val.js'></script> <script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.messages.js"></script>
<script type="text/javascript" src="/static-modules/moment/moment.js"></script> <script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.js"></script>
<script type="text/javascript" src="/static/lib/js/app-base.js"></script> <script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.validate.js"></script>
<script type="text/javascript" src="/static/js/app.js"></script> <script type="text/javascript" src="/static/js/app.js"></script>
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark"> <nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a> <a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent"> <div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
<ul class="navbar-nav top-nav"> <ul class="navbar-nav top-nav">
<li class="nav-item group-required group-required-app_sso_admin"> <%# Items gated on a group start hidden (.group-required) and are
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i> revealed by app-base.js for the groups the user is in. %>
Users <% for(const item of ui.nav){ %>
</a> <li class="nav-item<%- item.groups.length ? ' group-required' : '' %><%- item.groups.map(group => ' group-required-' + group).join('') %>">
</li> <a class="nav-link" href="<%- item.href %>"><i class="<%- item.icon %>"></i>
<li class="nav-item group-required group-required-app_sso_admin"> <%- item.label %>
<a class="nav-link" href="/groups"><i class="fa-solid fa-users-viewfinder"></i> </a>
Groups </li>
</a> <% } %>
</li> </ul>
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_directory_admin"> <div class="form-inline mt-2 mt-md-0">
<a class="nav-link" href="/directory"><i class="fa-solid fa-server"></i> <% if(ui.profileUrl){ %>
Directory <a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
</a> <i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</li> </a>
<% } else { %>
<span id="cl-username" class="navbar-text text-light me-3" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</span>
<% } %>
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
<i class="fas fa-sign-in"></i>
Login
</a>
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(function(){ window.location.href = '<%- ui.logoutRedirect %>'; })" style="display: none;">
<i class="fas fa-sign-out"></i>
Log Out
</button>
</div>
</div>
</nav>
<% if(ui.updateCheck){ %>
<!-- Admin-only "a newer release is available" notice (services/update_check.js).
Dismissal is per-browser-session only (sessionStorage), not persisted server-side.
Fixed-positioned below the fixed navbar (a plain in-flow div here would render
UNDER the nav, since fixed elements are taken out of document flow) -- shown/hidden
dynamically, so #spa-shell's margin-top is adjusted in JS to make room for it. -->
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
<span id="update-banner-text"></span>
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
</div>
<script type="text/javascript">
function showUpdateBanner(){
let $nav = $('nav.fixed-top');
let $banner = $('#update-banner');
$banner.css('top', $nav.outerHeight() + 'px').show();
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
}
function dismissUpdateBanner(){
$('#update-banner').hide();
$('#spa-shell').css('margin-top', '');
sessionStorage.setItem('update-banner-dismissed', '1');
}
function checkForUpdate(){
if(sessionStorage.getItem('update-banner-dismissed')) return;
app.api.get('update-check', function(error, info){
if(error || !info || !info.updateAvailable) return;
$('#update-banner-text').html(
'A newer version of <%- ui.updateLabel %> is available: <b>v' + info.latestVersion + '</b> ' +
'(running v' + info.currentVersion + ') — ' +
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
);
showUpdateBanner();
});
}
</script>
<% } %>
<script type="text/javascript">
$(document).ready(function(){
// Set the correct link to active in the top nav bar
$('.top-nav a').each(function(index){
let $this = $(this);
$this.removeClass('active');
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
$this.addClass('active')
}
})
// Set the correct login/logout button, and reveal the current user's
// name once we know who they are. Group-gated nav items are revealed
// by app-base.js off the same cached user/me.
app.auth.isLoggedIn(function(error, me){
if(me){
$('#cl-logout-button').show();
let username = me.uid || me.username;
if(username){
$('#cl-username-text').text(username);
$('#cl-username').css('display', '');
}
<% if(ui.updateCheck){ %>
if(me.isAdmin) checkForUpdate();
<% } %>
}else{
$('#cl-login-button').show();
}
});
});
</script>
<li class="nav-item group-required group-required-app_sso_admin"> <!-- Container -->
<a class="nav-link" href="/executive"> <div id="spa-shell" class="container-fluid">
<i class="fa-solid fa-gauge-high"></i> <div class="actionMessage" style="display:none;"></div>
Executive
</a>
</li>
</ul>
<div class="form-inline mt-2 mt-md-0">
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a>
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
<i class="fas fa-sign-out"></i>
Login
</a>
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(e => window.location.href='/')" style="display: none;">
<i class="fas fa-sign-out"></i>
Log Out
</button>
</div>
</div>
</nav>
<div id="update-banner" class="alert alert-info alert-dismissible mb-0 rounded-0 text-center" style="display:none; position:fixed; left:0; right:0; z-index:1029;">
<span id="update-banner-text"></span>
<button type="button" class="btn-close" onclick="dismissUpdateBanner()"></button>
</div>
<script type="text/javascript">
function showUpdateBanner(){
let $nav = $('nav.fixed-top');
let $banner = $('#update-banner');
$banner.css('top', $nav.outerHeight() + 'px').show();
$('#spa-shell').css('margin-top', ($nav.outerHeight() + $banner.outerHeight()) + 'px');
}
function dismissUpdateBanner(){
$('#update-banner').hide();
$('#spa-shell').css('margin-top', '');
sessionStorage.setItem('update-banner-dismissed', '1');
}
$(document).ready(async function(){
// Set the correct link to active in the top nav bar
$('.top-nav a').each(function(index){
let $this = $(this);
$this.removeClass('active');
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
$this.addClass('active')
}
})
// Set the correct login/logout button, and reveal the current user's
// name (linking to their profile) once we know who they are.
var me = await app.auth.isLoggedIn();
if(me){
$('#cl-logout-button').show();
if(me.uid){
$('#cl-username-text').text(me.uid);
$('#cl-username').css('display', '');
}
if(await app.auth.memberOf('app_sso_admin', me) && !sessionStorage.getItem('update-banner-dismissed')){
app.api.get('update-check', function(error, info){
if(error || !info || !info.updateAvailable) return;
$('#update-banner-text').html(
'A newer version of SSO Manager is available: <b>v' + info.latestVersion + '</b> ' +
'(running v' + info.currentVersion + ') — ' +
'<a href="' + info.releaseUrl + '" target="_blank" class="alert-link">see what changed</a>.'
);
showUpdateBanner();
});
}
}else{
$('#cl-login-button').show();
}
});
</script>
<!-- Container -->
<div id="spa-shell" class="container-fluid">
<div class="actionMessage" style="display:none;"></div>
+2 -5
View File
@@ -64,7 +64,7 @@ async function fetchUsernameSuggestions() {
// simply can't bind). Disabling (not just hiding) keeps disabled // simply can't bind). Disabling (not just hiding) keeps disabled
// fields out of both form serialization and validation. // fields out of both form serialization and validation.
$form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked); $form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
$form.find('[name=mobile]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
$form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked); $form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
$form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked); $form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
@@ -137,10 +137,7 @@ async function fetchUsernameSuggestions() {
<input type="text" class="form-control shadow" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." /> <input type="text" class="form-control shadow" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." />
</div> </div>
<div class="mb-3">
<label class="form-label">Mobile Phone <small class="text-muted">(optional, include country code e.g. +14155551234)</small></label>
<input type="text" class="form-control shadow" name="mobile" placeholder="+14155551234" />
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Location (Site) <small class="text-muted">(optional)</small></label> <label class="form-label">Location (Site) <small class="text-muted">(optional)</small></label>
+4 -4
View File
@@ -6,7 +6,7 @@
function renderUsers(){ function renderUsers(){
app.user.list(function(error, data){ app.user.list(function(error, data){
if(error){ if(error){
app.util.actionMessage(data.message, $('#tab-people'), 'danger'); app.messages.action(data.message, $('#tab-people'), 'danger');
return; return;
} }
$.scope.userRow.empty(); $.scope.userRow.empty();
@@ -128,7 +128,7 @@
async function revokeInvite(tokenId, btn) { async function revokeInvite(tokenId, btn) {
$thisRow = $(btn).closest('tr'); $thisRow = $(btn).closest('tr');
$thisRow.addClass('table-warning'); $thisRow.addClass('table-warning');
let confirmation = await app.util.actionConfirm('Revoke selected invite token?', $thisRow, 'warning'); let confirmation = await app.messages.confirm('Revoke selected invite token?', $thisRow, 'warning');
if(!confirmation){ if(!confirmation){
$thisRow.removeClass('table-warning'); $thisRow.removeClass('table-warning');
return; return;
@@ -153,12 +153,12 @@
async function deleteUser(uid, btn){ async function deleteUser(uid, btn){
const $row = $(btn).closest('tr'); const $row = $(btn).closest('tr');
$row.addClass('table-warning'); $row.addClass('table-warning');
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $row, 'warning'); const confirmed = await app.messages.confirm(`Delete user "${uid}"?`, $row, 'warning');
$row.removeClass('table-warning'); $row.removeClass('table-warning');
if (!confirmed) return; if (!confirmed) return;
app.api.delete('user/' + uid, function(error, data){ app.api.delete('user/' + uid, function(error, data){
if (error) { if (error) {
app.util.actionMessage(data.message || 'Failed to delete user', $row, 'danger'); app.messages.action(data.message || 'Failed to delete user', $row, 'danger');
return; return;
} }
renderUsers(); renderUsers();
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# seed-test-user.sh — Create the test user in LDAP for the test suite.
#
# The test suite (tests/setup.js) logs in as uid=test / password=MyTestPassword!2.
# This script creates that user in the LDAP directory along with its personal
# posixGroup, and adds it to the app_sso_admin group so admin-gated tests pass.
#
# Environment variables (from docker-compose.test.yml):
# LDAP_HOST — LDAP server hostname (default: ldap)
# LDAP_PORT — LDAP server port (default: 389)
# BIND_DN — LDAP admin bind DN (default: cn=admin,dc=test,dc=local)
# BIND_PW — LDAP admin password (default: secret)
# BASE_DN — LDAP base DN (default: dc=test,dc=local)
set -euo pipefail
LDAP_HOST="${LDAP_HOST:-ldap}"
LDAP_PORT="${LDAP_PORT:-389}"
BIND_DN="${BIND_DN:-cn=admin,dc=test,dc=local}"
BIND_PW="${BIND_PW:-secret}"
BASE_DN="${BASE_DN:-dc=test,dc=local}"
LDAP_URI="ldap://${LDAP_HOST}:${LDAP_PORT}"
USER_UID="test"
USER_PASSWORD="MyTestPassword!2"
info() { echo "[INFO] $*"; }
error() { echo "[ERROR] $*" >&2; }
# ── Wait for LDAP to be reachable ────────────────────────────────────────────
info "Waiting for LDAP at ${LDAP_URI}..."
for i in $(seq 1 30); do
if ldapsearch -x -H "$LDAP_URI" -b '' -s base '(objectClass=*)' >/dev/null 2>&1; then
info "LDAP is reachable"
break
fi
if [ "$i" -eq 30 ]; then
error "LDAP not reachable after 30 attempts"
exit 1
fi
sleep 1
done
# ── Check if the test user already exists ────────────────────────────────────
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${USER_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Test user '${USER_UID}' already exists — skipping seed"
exit 0
fi
# ── Generate the SSHA512 password hash ───────────────────────────────────────
# Inline the hash function to avoid requiring the full model chain (which
# tries to connect to Redis/LDAP during module loading and would hang).
info "Generating password hash..."
PASSWORD_HASH=$(node -e "
const crypto = require('crypto');
const salt = crypto.randomBytes(8);
const hash = crypto.createHash('sha512').update('${USER_PASSWORD}').update(salt).digest();
console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'));
")
info "Password hash generated"
# ── Create a temporary LDIF file ─────────────────────────────────────────────
TMPFILE=$(mktemp /tmp/seed-test-user.XXXXXX)
trap 'rm -f "$TMPFILE"' EXIT
cat > "$TMPFILE" << LDIF
# Personal posixGroup for the test user
dn: cn=${USER_UID},ou=groups,${BASE_DN}
objectClass: posixGroup
objectClass: top
cn: ${USER_UID}
gidNumber: 1500
description: Personal group for test user
# Test user posixAccount
dn: cn=${USER_UID},ou=people,${BASE_DN}
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: top
objectClass: theta42Person
objectClass: ldapPublicKey
objectClass: sudoRole
cn: ${USER_UID}
sn: Test
uid: ${USER_UID}
uidNumber: 1500
gidNumber: 1500
homeDirectory: /home/${USER_UID}
loginShell: /bin/bash
mail: test@test.local
userPassword: ${PASSWORD_HASH}
description: Test user for automated test suite
sudoHost: ALL
sudoCommand: ALL
sudoUser: ${USER_UID}
LDIF
# ── Add the entries to LDAP ──────────────────────────────────────────────────
info "Creating test user '${USER_UID}' in LDAP..."
ldapadd -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" -f "$TMPFILE" 2>/dev/null || true
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${USER_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Test user '${USER_UID}' exists or was created"
else
error "Failed to create test user '${USER_UID}'"
exit 1
fi
# ── Add the test user to required SSO groups ─────────────────────────────────
info "Adding test user to SSO admin groups..."
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
ldapmodify -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" << EOF 2>/dev/null || true
dn: cn=${group},ou=groups,${BASE_DN}
changetype: modify
add: member
member: cn=${USER_UID},ou=people,${BASE_DN}
EOF
done
info "Test user added to SSO admin groups"
# ── Create additional users needed by tests ─────────────────────────────────
# wmantly is referenced by OTP (otp.test.js) and impersonation (impersonate.test.js)
# tests as an existing non-admin user.
WMANTLY_UID="wmantly"
if ! ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${WMANTLY_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Creating additional test user '${WMANTLY_UID}'..."
# Generate password hash for wmantly
WMANTLY_HASH=$(node -e "
const crypto = require('crypto');
const salt = crypto.randomBytes(8);
const hash = crypto.createHash('sha512').update('testpass').update(salt).digest();
console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'));
")
cat > "$TMPFILE" << LDIF
dn: cn=${WMANTLY_UID},ou=groups,${BASE_DN}
objectClass: posixGroup
objectClass: top
cn: ${WMANTLY_UID}
gidNumber: 1501
dn: cn=${WMANTLY_UID},ou=people,${BASE_DN}
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: top
objectClass: theta42Person
objectClass: ldapPublicKey
objectClass: sudoRole
cn: ${WMANTLY_UID}
sn: Mantly
uid: ${WMANTLY_UID}
uidNumber: 1501
gidNumber: 1501
homeDirectory: /home/${WMANTLY_UID}
loginShell: /bin/bash
mail: wmantly@test.local
userPassword: ${WMANTLY_HASH}
sudoHost: ALL
sudoCommand: ALL
sudoUser: ${WMANTLY_UID}
LDIF
ldapadd -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" -f "$TMPFILE" 2>/dev/null || true
if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \
-b "cn=${WMANTLY_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then
info "Additional test user '${WMANTLY_UID}' exists or was created"
else
error "Failed to create additional test user '${WMANTLY_UID}'"
exit 1
fi
fi
info "Seed complete — all test users are ready"
+2
View File
@@ -0,0 +1,2 @@
const { hashPasswordSSHA512 } = require('./nodejs/models/user_ldap.js');
console.log(hashPasswordSSHA512('MyTestPassword!2'));