Compare commits

...

26 Commits

Author SHA1 Message Date
wmantly b4fa824609 feat: configurable LDAPS hostname (ldapsHost/ldapsPort) and extensive docs (#89)
Add conf.ldap.ldapsHost / conf.ldap.ldapsPort so the /integrations page
can advertise an internal-only LDAPS hostname separate from the public
OAuth issuer. This avoids forcing admins to port-forward 636 publicly.

- routes/index.js derives LDAPS URL from ldapsHost/ldapsPort with issuer fallback
- integrations.ejs adds a contextual help panel explaining TLS hostname
  validation, the public-issuer default, and recommended internal-DNS /
  Docker-internal alternatives
- conf/base.js, secrets.js.example, DEPLOYMENT.md, docs/configuration.md,
  and docs/ldap.md document and expose the new options
- Add tests/integrations.test.js for default and custom ldapsHost behavior
- Bump version to 1.1.17

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-19 01:13:43 -04:00
wmantly 5a8030fd7d chore(release): public-release readiness and security fixes for 1.1.16
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-18 23:14:38 -04:00
wmantly cf80c966eb security: swap sanitizer to xss and harden logging
- Replace isomorphic-dompurify with xss to avoid ESM-only transitive
  dependencies (jsdom/htmlparser2) that break the existing Jest test suite.
- Sanitize rendered docs and Terms-of-Service HTML via xss() in routes/docs.js
  and routes/index.js.
- Remove full-object new-user logging from models/user_ldap.js and reduce
  login-path error output to error.name/error.message only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 23:03:33 -04:00
wmantly 07819a6254 security: sanitize markdown output and reduce PII logging
- Add isomorphic-dompurify to sanitize rendered docs HTML and Terms of Service
- Remove addLdapUser full-object logging that included password hashes
- Log only error name/message on auth/login failures instead of full LDAP error objects

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:56:35 -04:00
wmantly 1b3e842006 ci: set app_oauth__jwtSecret for test runs
routes/oauth.js now validates jwtSecret at module load time, so CI must
provide a non-placeholder value for the test runner.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:16:30 -04:00
wmantly efe3e514b0 chore(release): public-release readiness and security fixes for 1.1.16
Security:
- Escape user-supplied values in LDAP filters and DNs (group_ldap.js, user_ldap.js)
- Replace Math.random() token/UUID/OTP generation with crypto.randomUUID / crypto.randomInt
- Refuse startup when oauth.jwtSecret is missing or placeholder

Fixes:
- Correct from-address template rendering in email.js

Packaging:
- Remove private flag and bump version to 1.1.16

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 22:08:11 -04:00
wmantly 37f2ece172 Merge pull request #87 from theta42/release-1.1.15
Bump version to 1.1.15
2026-07-18 01:20:13 -04:00
wmantly b77704089b Bump version to 1.1.15; update CHANGELOG
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 01:18:05 -04:00
wmantly 114d8c86ca Merge pull request #86 from theta42/install-script-rework
Rewrite install.sh as a git-clone installer, add a one-line install
2026-07-18 01:17:18 -04:00
wmantly 3e87ad86ab Rewrite install.sh as a git-clone installer, add a one-line install
Replaces the old flag-driven, copy-based installer with an idempotent
git-clone-and-symlink installer matching theta42/proxy's ops/install.sh
pattern, so `wget -O - .../install.sh | sudo bash` works the same way
for both apps:

- Installs to /opt/theta42/sso-manager (was /opt/sso-manager, and the
  repo had to already be checked out locally -- now it clones itself).
- First run only: bootstraps OpenLDAP (modules, overlays, schema,
  directory tree, SSO groups -- ops/ldap-setup.sh) with a generated
  admin password + JWT secret, and seeds /etc/sso-manager/secrets.js
  (was /opt/sso-manager/conf/secrets.js, hand-filled from CLI flags).
  Later runs never touch LDAP or the secrets file again.
- ops/systemd/sso-manager.service now points at the new install path
  and sets CONF_SECRETS=/etc/sso-manager/secrets.js (requires
  @simpleworkjs/conf >= 1.2.0, already the pinned version) instead of
  the app needing a config file inside the repo checkout.
- Prints the version it's updating from/to (or "Already up to date")
  on every run, instead of updating silently.

Two real bugs found and fixed while testing this end-to-end in a clean
container:
- The debconf `slapd/domain` value was computed as
  `${LDAP_BASE_DN#dc=}` ("example,dc=com" for "dc=example,dc=com")
  instead of a proper dotted domain -- slapd's postinst hangs
  indefinitely on a malformed domain instead of failing cleanly.
  Fixed to derive it the same way the secrets file already did
  ("example.com").
- ops/ldap-setup.sh's ppolicy-overlay checks used an LDAP substring
  filter, `(olcOverlay=*ppolicy*)`, against an attribute that doesn't
  support substring matching -- it silently matched nothing even when
  the overlay was correctly configured (stored as "{0}ppolicy"),
  so the final verification always reported failure and `set -e`
  aborted the installer after LDAP was set up but before the app was.
  Fixed to filter on `(objectClass=olcOverlayConfig)` and let the
  existing DN-based grep narrow it down, matching the pattern already
  used by every other check in that script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 01:13:51 -04:00
wmantly c5a2c0a71d Merge pull request #85 from theta42/release-1.1.14
Bump version to 1.1.14
2026-07-17 23:46:43 -04:00
wmantly fe23d231be Bump version to 1.1.14; update CHANGELOG
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:44:34 -04:00
wmantly 3a612dbed7 Merge pull request #84 from theta42/bump-conf-jqrepeat
Bump @simpleworkjs/conf to 1.2.0, jq-repeat to 2.2.0
2026-07-17 23:41:19 -04:00
wmantly f154bb8db0 Bump @simpleworkjs/conf to 1.2.0, jq-repeat to 2.2.0
conf 1.2.0 adds CONF_SECRETS, an env var to point at the secrets file
directly -- use it in the Docker entrypoint instead of symlinking the
mounted file into /app/conf/secrets.js, so the app no longer needs
write access to its own conf/ directory to pick up mounted secrets.
jq-repeat 2.2.0 is a compatible feature release (sort(), replace(),
faster leading-edge update() timing); no call-site changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 23:38:56 -04:00
wmantly 9fc5abda2a Merge pull request #83 from theta42/fix-changelog-corruption
Fix CHANGELOG.md corruption (v1.1.10 merged into v1.1.11)
2026-07-17 22:22:21 -04:00
wmantly a6ee985de4 Fix CHANGELOG.md: restore separate heading for v1.1.10
A repeated Edit-tool bump had overwritten the previous top version
heading instead of inserting a new one above it, silently merging
v1.1.10's release notes into v1.1.11's section with the v1.1.10
heading missing entirely. The underlying content was still present,
just missing its own "## [1.1.10]" header -- restored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 22:20:02 -04:00
wmantly 47a9f6c3ec Merge pull request #82 from theta42/fix-doc-link-slugs
Resolve doc cross-links by real filename as a fallback
2026-07-17 22:06:08 -04:00
wmantly f6552cb741 Resolve doc cross-links by real filename as a fallback
The new concept docs (and their "See also" reciprocal links) reference
each other by real filename -- "concepts-accounts.html" -- which is the
correct, working URL on the Jekyll/GitHub Pages build (a page's URL there
IS its filename stem), but doesn't match this viewer's own short slugs
(DOCS keys, e.g. "accounts" -> /docs/accounts), so fixDocLinks() left
those links unrewritten and 404ing in-app.

Rather than rewrite the docs to two different link forms depending on
target, resolve by filename as a fallback when the slug lookup misses --
one link written in a doc now works correctly on both targets.

Bumps to v1.1.13.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 22:03:52 -04:00
wmantly 4e7e29b35f Merge pull request #81 from theta42/concept-docs
Add plain-language concept docs; fix docs viewer rendering; link API tokens
2026-07-17 21:51:40 -04:00
wmantly 4e5a2aa4f9 Add plain-language concept docs; fix docs viewer rendering; link API tokens
- New docs/concepts-{accounts,oauth-apps,api-tokens}.md -- plain-language
  guides aimed at less technical readers, each linking onward to the
  existing schema/protocol-level doc for anyone who wants that detail.
  Card help links (Users, Groups, OAuth cards, My groups, Members of
  <uid>'s group) now point here instead of straight at the technical
  docs; the LDAP-protocol-wiring cards (raw connection details for
  connecting a 3rd-party app) stay pointed at the technical ldap.md,
  since that's genuinely the right depth for that task.
- The "New API Token" card had no help link at all -- added, pointing to
  the new API Tokens doc.
- Fixed the in-app docs viewer rendering every docs/*.md page with a
  garbled heading + stray <hr> at the top: Jekyll front matter (meant
  only for the GitHub Pages build) was never stripped before being
  handed to the markdown renderer. Also fixed: cross-doc links
  (ldap.html, index.html, etc.) never resolved in-app, since this
  viewer serves docs at /docs/<slug> with no .html suffix -- rewritten
  to the correct in-app URL, same idea as the existing image-path fix.

Bumps to v1.1.12.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 21:49:27 -04:00
wmantly 51784f2f27 Merge pull request #80 from theta42/help-icon-relocate
Move help links from the global header onto each relevant card
2026-07-17 19:56:29 -04:00
wmantly 4c59b1fabb Move help links from the global header onto each relevant card
The single header-wide help icon (added last release) pointed at a
per-page doc guess, but a page can have several cards covering different
topics (e.g. Integrations has both OAuth and LDAP cards). Removed it and
added a small help icon directly to each card that has real corresponding
doc content, linking straight to that doc -- Invite User/Add new
user/User List/Service Accounts (users.ejs), group cards (groups.ejs),
OAuth Apps + LDAP connection cards (integrations.ejs), My groups/Members
of <uid>'s group/New API Token (profile.ejs).

Bumps to v1.1.11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:54:19 -04:00
wmantly 099638057e Merge pull request #79 from theta42/docs-help-search
Add header help icon and in-app docs search
2026-07-17 19:24:33 -04:00
wmantly 077c41844d Add header help icon and in-app docs search
- A ? icon in the top-right header deep-links to the doc most relevant to
  the current page (client-side path mapping, same pattern already used
  for top-nav active-link highlighting -- no server-side "current section"
  local exists to key off of instead). Falls back to the docs index.
- GET /docs/search does a plain line-substring search over the existing
  allowlisted doc set. No new dependency, stays usable with no internet
  access.

Bumps to v1.1.10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 19:22:27 -04:00
wmantly 96adf60cf7 Merge pull request #78 from theta42/personal-group-members
Add personal Unix group member management
2026-07-17 11:31:11 -04:00
wmantly 82f703f560 Add personal Unix group member management
Every account gets a personal posixGroup at creation (its primary GID
holder) but there was no way to manage its memberUid list -- add
add/remove endpoints and a profile-page UI (admin-only), reusing the
userSelect widget already built for the manager field.

Bumps to v1.1.9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 11:28:41 -04:00
36 changed files with 1250 additions and 806 deletions
+4
View File
@@ -136,6 +136,10 @@ jobs:
# directory layout (dc=example,dc=com) -- only the admin password # directory layout (dc=example,dc=com) -- only the admin password
# (normally supplied via a gitignored secrets.js) needs setting. # (normally supplied via a gitignored secrets.js) needs setting.
app_ldap__bindPassword: your-ldap-password app_ldap__bindPassword: your-ldap-password
# routes/oauth.js now refuses to start without a real jwtSecret.
# This is a non-secret test value; the container under test uses
# secrets.js.example's jwtSecret independently.
app_oauth__jwtSecret: ci-test-jwt-secret-do-not-use-in-production
run: npm test run: npm test
test-summary: test-summary:
+5
View File
@@ -1,5 +1,10 @@
# SSO Manager API Documentation # SSO Manager API Documentation
> Looking for a plainer explanation of what API tokens are and when you'd
> want one, instead of a full endpoint reference? See
> [API Tokens](/docs/api-tokens) (in-app) or
> [concepts-api-tokens.md](docs/concepts-api-tokens.md) (repo).
## Overview ## Overview
API documentation for the SSO Manager Node application. Provides endpoints for authentication, user management, group management, token management, notifications, and OAuth 2.0 / OpenID Connect. API documentation for the SSO Manager Node application. Provides endpoints for authentication, user management, group management, token management, notifications, and OAuth 2.0 / OpenID Connect.
+80 -1
View File
@@ -6,6 +6,78 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased] ## [Unreleased]
## [1.1.17] - 2026-07-18
### Added
- `conf.ldap.ldapsHost` and `conf.ldap.ldapsPort` config options (also settable via `app_ldap__ldapsHost` / `app_ldap__ldapsPort`). When `ldapsHost` is set, the `/integrations` page advertises that hostname for direct LDAPS binds instead of deriving it from the public OAuth issuer. This lets operators use an internal-only hostname (e.g. `ldap.internal.example.com` or `sso-manager` on the Docker network) and avoid port-forwarding 636 to the internet.
- A contextual help panel on `/integrations` → LDAP explaining why LDAPS needs a hostname (not an IP), why 636 should not be publicly forwarded, and the recommended internal-DNS / Docker-internal alternatives.
### Changed
- `routes/index.js` now computes the displayed LDAPS URL from `conf.ldap.ldapsHost`/`ldapsPort` with fallback to the OAuth issuer host for backward compatibility.
- `secrets.js.example`, `docs/configuration.md`, `docs/ldap.md`, and `DEPLOYMENT.md` document the new `ldapsHost`/`ldapsPort` options and recommended network layouts.
- Bumped version to `1.1.17` in `nodejs/package.json`.
## [1.1.16] - 2026-07-18
### Security
- Hardened LDAP filter and DN construction against injection. All user-supplied values interpolated into group filters (`models/group_ldap.js`) and RDN values used when adding users/groups (`models/user_ldap.js`) are now escaped before being sent to the LDAP server.
- Replaced `Math.random()`-based token generation in `models/token.js`, `models/oauth_code.js`, and `models/oauth_client.js` with `crypto.randomUUID()` for session tokens, OAuth codes, access/refresh tokens, and client IDs.
- Replaced `Math.random()`-based OTP generation in `OtpToken.issue()` with `crypto.randomInt()`.
- `routes/oauth.js` now refuses to start if `oauth.jwtSecret` is missing or still set to the placeholder value, instead of falling back to a hardcoded public string.
- Rendered docs and Terms-of-Service HTML in `routes/docs.js` and `routes/index.js` are now sanitized with `xss` to prevent stored XSS from malicious markdown.
- Removed a `console.log` that wrote new-user data (including password hashes) to the log in `models/user_ldap.js`; reduced login-path error logging to `error.name`/`error.message` only.
### Changed
- Public-release packaging: removed `"private": true` from `nodejs/package.json` and bumped version to `1.1.16`.
- CI workflow (`.github/workflows/pr-tests.yml`) now sets `app_oauth__jwtSecret` so the test suite can run against the new startup-time JWT validation.
### Fixed
- `models/email.js`: fixed a template bug where the rendered `from` address used `template.message` instead of `template.from`.
## [1.1.15] - 2026-07-18
### Changed
- Rewrote `install.sh` as an idempotent git-clone installer, replacing the old flag-driven, copy-based one — `wget -O - .../install.sh | sudo bash` now works the same way it does for theta42/proxy. Installs to `/opt/theta42/sso-manager` (was `/opt/sso-manager`). First run only: bootstraps OpenLDAP with a generated admin password + JWT secret and seeds `/etc/sso-manager/secrets.js` (was `/opt/sso-manager/conf/secrets.js`, hand-filled from CLI flags); later runs never touch LDAP or the secrets file again. `ops/systemd/sso-manager.service` sets `CONF_SECRETS=/etc/sso-manager/secrets.js` to match.
- `install.sh` now prints the version it's updating from/to (or "Already up to date") on every run.
### Fixed
- `install.sh` could hang indefinitely on a fresh host if a base package pulled in `tzdata` as a new dependency (no TTY for the interactive timezone prompt), or if the debconf `slapd/domain` value was malformed (a raw DN fragment instead of a dotted domain) — slapd's postinst hangs rather than failing cleanly on a bad domain. Both fixed.
- `ops/ldap-setup.sh`'s ppolicy-overlay checks used an LDAP substring filter against an attribute that doesn't support substring matching, so they always reported the overlay as unconfigured even when it was correctly set up (stored as `{0}ppolicy`) — the final verification step always failed as a result. Fixed to filter on `(objectClass=olcOverlayConfig)` instead, matching every other check in that script.
## [1.1.14] - 2026-07-17
### Changed
- Bumped `@simpleworkjs/conf` to 1.2.0 and `jq-repeat` to 2.2.0. The Docker entrypoint now sets the new `CONF_SECRETS` env var to point directly at a mounted `sso-secrets.js` instead of symlinking it into `/app/conf/secrets.js` — the app no longer needs write access to its own `conf/` directory to pick up mounted secrets.
## [1.1.13] - 2026-07-17
### Fixed
- The new concept docs' cross-links (`concepts-accounts.html` etc.) are the correct, working URL on the Jekyll/GitHub Pages build (where the page's URL is its filename stem) but didn't resolve in the in-app docs viewer, which serves docs at a separate short slug (`/docs/accounts`). The in-app renderer now also resolves a doc's real filename as a fallback, so one link written in a doc works on both targets.
## [1.1.12] - 2026-07-17
### Added
- Three new plain-language docs aimed at less technical readers, replacing the schema-level LDAP/OAuth/API docs as the target of most card help links: **Accounts, Groups & Managers**, **Connecting Apps (SSO)**, and **API Tokens**. Each links onward to the deeper technical reference for readers who want it; the technical docs link back the other way too. The personal-access-token card (previously missed) now links to its own doc.
### Fixed
- The in-app docs viewer rendered every `docs/*.md` page with a garbled heading and a stray horizontal rule at the top — Jekyll front matter (meant only for the GitHub Pages build) was never stripped before being handed to the markdown renderer. Also fixed: cross-doc links (`ldap.html`, `index.html`, etc.) never resolved in-app, since this viewer serves docs at `/docs/<slug>` with no `.html` suffix — they're now rewritten to the correct in-app URL, the same way image paths already were.
## [1.1.11] - 2026-07-17
### Changed
- Moved the help (❓) link out of the global header and onto each relevant card individually (Invite User, Add new user, User List, Service Accounts, group cards, OAuth/LDAP integration cards, My groups, Members of `<uid>`'s group, New API Token) — each now deep-links straight to the doc that actually covers it, instead of one generic header icon.
## [1.1.10] - 2026-07-17
### Added
- A help icon (❓) in the top-right header now deep-links to the doc most relevant to the current page (falls back to the docs index elsewhere).
- The in-app docs viewer (`/docs`) is now searchable — a simple line-substring search over the same local doc set, no new dependency, still works with no internet access.
## [1.1.9] - 2026-07-17
### Added
- Every account's personal Unix group (its primary GID holder) can now have supplementary members managed from the account's profile page ("Members of `<uid>`'s group", admin-only) — e.g. to share write access to files owned by that group. Uses the standard `memberUid` attribute (RFC 2307 `posixGroup`).
## [1.1.8] - 2026-07-17 ## [1.1.8] - 2026-07-17
### Added ### Added
@@ -72,7 +144,14 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts. - Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
- Merged OAuth Apps + LDAP Info into a single Integrations page. - Merged OAuth Apps + LDAP Info into a single Integrations page.
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.8...HEAD [Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.16...HEAD
[1.1.15]: https://github.com/theta42/sso-manager-node/compare/v1.1.14...v1.1.15
[1.1.14]: https://github.com/theta42/sso-manager-node/compare/v1.1.13...v1.1.14
[1.1.13]: https://github.com/theta42/sso-manager-node/compare/v1.1.12...v1.1.13
[1.1.12]: https://github.com/theta42/sso-manager-node/compare/v1.1.11...v1.1.12
[1.1.11]: https://github.com/theta42/sso-manager-node/compare/v1.1.10...v1.1.11
[1.1.10]: https://github.com/theta42/sso-manager-node/compare/v1.1.9...v1.1.10
[1.1.9]: https://github.com/theta42/sso-manager-node/compare/v1.1.8...v1.1.9
[1.1.8]: https://github.com/theta42/sso-manager-node/compare/v1.1.7...v1.1.8 [1.1.8]: https://github.com/theta42/sso-manager-node/compare/v1.1.7...v1.1.8
[1.1.7]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...v1.1.7 [1.1.7]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...v1.1.7
[1.1.6]: https://github.com/theta42/sso-manager-node/compare/v1.1.5...v1.1.6 [1.1.6]: https://github.com/theta42/sso-manager-node/compare/v1.1.5...v1.1.6
+48 -32
View File
@@ -188,6 +188,12 @@ valid 10 years, SAN includes the CN + `localhost` + `127.0.0.1`) and listens on
`ldap-certs` volume so it persists across container recreation — clients don't need `ldap-certs` volume so it persists across container recreation — clients don't need
to re-trust on every rebuild. to re-trust on every rebuild.
The `/integrations` page derives its LDAPS URL from the OAuth issuer by default.
To advertise a separate, internal-only hostname (e.g. `ldap.internal.example.com`
or `sso-manager` for Docker-internal clients), set `conf.ldap.ldapsHost` in your
secrets file or pass `app_ldap__ldapsHost=...`. See `docs/ldap.md` for
recommended network layouts and how to match the cert SAN to the hostname.
- **Trusting the self-signed cert** (clients): copy `/etc/openldap/certs/ldap.crt` - **Trusting the self-signed cert** (clients): copy `/etc/openldap/certs/ldap.crt`
out of the container and add it to the client's trusted CA store, or set out of the container and add it to the client's trusted CA store, or set
`TLS_REQCERT never` for quick-and-dirty LAN use. Fetch it with: `TLS_REQCERT never` for quick-and-dirty LAN use. Fetch it with:
@@ -332,10 +338,17 @@ OAuth clients live in SSO Redis and are preserved by the volume.
## Method 2: Bare metal (Debian/Ubuntu) ## Method 2: Bare metal (Debian/Ubuntu)
`install.sh` is an idempotent installer: it installs Node.js 20.x, installs and `install.sh` is an idempotent installer: it installs Node.js 22.x and Redis,
configures OpenLDAP (modules + overlays + custom schema + directory tree + force-syncs the repo to `/opt/theta42/sso-manager`, and symlinks the systemd
required groups), deploys the app to `/opt/sso-manager`, and creates a systemd config from the repo. Re-run it to update — it prints the version you're
unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based). updating from and to (or "Already up to date" if there's nothing new).
On the **first run only** it also installs and configures OpenLDAP (modules +
overlays + custom schema + directory tree + required groups — see
`ops/ldap-setup.sh`) and seeds `/etc/sso-manager/secrets.js` with a generated
LDAP admin password and JWT secret (SMTP is left as a placeholder). Once that
file exists it's never touched again, and LDAP is never re-bootstrapped —
edit the file and restart the service to change anything.
### Prerequisites ### Prerequisites
@@ -346,47 +359,50 @@ unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based
### Install ### Install
```bash ```bash
sudo ./install.sh \ wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
-p 'your-ldap-password' \
-b 'dc=yourdomain,dc=com' \
-n 'Your Org' \
-o 3001
``` ```
| Flag | Env var | Description | or, if you already have the repo checked out:
|------|---------|-------------|
| `-p, --admin-pass` | `LDAP_ADMIN_PASS` | LDAP admin password (required) | ```bash
| `-b, --base-dn` | `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) | sudo ./install.sh
| `-n, --org-name` | `ORG_NAME` | Org name (default `SSO Manager`) | ```
| `-o, --port` | `PORT` | HTTP port (default `3001`) |
| `-j, --jwt-secret` | `JWT_SECRET` | JWT secret (default auto-generated) | | Env var | Description |
| `-s, --smtp-config` | `SMTP_*` | SMTP as `host:port:user:pass` | |---------|-------------|
| `--skip-ldap` | `SKIP_LDAP` | Skip LDAP setup (use existing) | | `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) — first run only |
| `--skip-app` | `SKIP_APP` | LDAP setup only | | `LDAP_ADMIN_PASS` | LDAP admin password (default auto-generated) — first run only |
| `--dry-run` | `DRY_RUN` | Show actions without making changes | | `JWT_SECRET` | JWT secret (default auto-generated) — first run only |
| `ORG_NAME` | Org name (default `SSO Manager`) — first run only |
| `PORT` | HTTP port (default `3001`) — first run only |
| `SKIP_LDAP` | `true` to skip OpenLDAP bootstrap entirely (point at an existing server yourself) |
| `REPO_URL`, `REPO_DIR`, `BRANCH`, `SECRETS_FILE` | Override the defaults |
### Post-install ### Post-install
```bash ```bash
sudo systemctl enable --now sso-manager sudo systemctl status sso-manager
journalctl -fu sso-manager journalctl -fu sso-manager
curl http://localhost:3001/health # -> {"status":"ok"} curl http://localhost:3001/health # -> {"status":"ok"}
``` ```
### What `install.sh` does ### What `install.sh` does
1. Installs Node.js 20.x (NodeSource). 1. Installs Node.js 22.x (NodeSource) and Redis.
2. Installs OpenLDAP (`slapd`) with: `pw-sha2`, `ppolicy`, `memberof`, `refint` 2. Clones/updates the repo at `/opt/theta42/sso-manager`.
modules + overlays; the custom `theta42Person` schema (`dateOfBirth`); indexes; 3. **First run only:** installs OpenLDAP (`slapd`) with `pw-sha2`, `ppolicy`,
`ou=people`/`ou=groups`/`ou=policies`; a default `pwdPolicy`; and the SSO groups. `memberof`, `refint` modules + overlays; the custom `theta42Person` schema
3. Installs the app to `/opt/sso-manager` and runs `npm ci --omit=dev`. (`dateOfBirth`); indexes; `ou=people`/`ou=groups`/`ou=policies`; a default
4. Generates `conf/secrets.js` (LDAP/SMTP/JWT) and `conf/base.js` (generic defaults). `pwdPolicy`; and the SSO groups — then seeds `/etc/sso-manager/secrets.js`.
5. Installs `sso-manager.service` (systemd), enabled on boot. 4. Symlinks `ops/systemd/sso-manager.service` into `/etc/systemd/system` and
runs `npm ci --omit=dev`.
5. Enables and (re)starts the service.
> For an existing LDAP server, run `sudo ./install.sh --skip-ldap …` and point the > For an existing LDAP server, run with `SKIP_LDAP=true` and write
> app at it. For LDAP-only setup on a host that already runs the app elsewhere, use > `/etc/sso-manager/secrets.js` yourself (see `secrets.js.example`) before
> `--skip-app`. To (re)configure overlays on an already-installed slapd, prefer > starting the service. To (re)configure overlays on an already-installed
> `ops/ldap-setup.sh` (idempotent, auto-detects the user database). > slapd, use `ops/ldap-setup.sh` directly (idempotent, auto-detects the user
> database).
--- ---
+14 -13
View File
@@ -107,23 +107,24 @@ vars, LDAPS/TLS, and backups.
### 3. Bare metal on Debian/Ubuntu ### 3. Bare metal on Debian/Ubuntu
`install.sh` is an idempotent installer: it installs Node.js 20.x and OpenLDAP, An automated installer installs Node.js, Redis, and (on first run) OpenLDAP
configures the directory (modules, overlays, schema, the SSO groups), deploys configuring the directory (modules, overlays, schema, the SSO groups) and
the app to `/opt/sso-manager`, and creates a systemd unit. seeding `/etc/sso-manager/secrets.js` with a generated admin password and JWT
secret — then deploys the app to `/opt/theta42/sso-manager` and starts a
The only thing it requires is the LDAP admin password; the domain (base DN) systemd service:
defaults to `dc=example,dc=com` if you don't pass one:
```bash ```bash
sudo ./install.sh -p 'your-ldap-password' -b 'dc=yourdomain,dc=com' wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
sudo systemctl enable --now sso-manager
curl http://localhost:3001/health # -> {"status":"ok"}
``` ```
Run `sudo ./install.sh -h` for all flags (`-n` org name, `-o` port, `-j` JWT That's it — LDAP and the app are both live afterward. Edit
secret, `-s` SMTP, `--skip-ldap` to use an existing LDAP, `--dry-run`). Re-run `/etc/sso-manager/secrets.js` (org name, SMTP, a non-default base DN, ...) and
it to update. Full details in [DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: restart the service to customize. It's idempotent and safe to re-run —
Bare metal*. re-running it updates the app in place (never touching LDAP or the secrets
file again) and prints the version you're updating from and to (e.g. `Updated
v1.1.13 -> v1.1.14`), or `Already up to date` if there's nothing new. Full
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
## Architecture ## Architecture
+13 -12
View File
@@ -6,12 +6,12 @@
# production, run a dedicated LDAP server and point the app at it via app_* # production, run a dedicated LDAP server and point the app at it via app_*
# env vars (or a mounted conf/secrets.js) using the app-only image. # env vars (or a mounted conf/secrets.js) using the app-only image.
# #
# The app reads its configuration from conf/base.js + conf/secrets.js, deep-merged # The app reads its configuration from conf/base.js + a secrets file, deep-merged
# by @simpleworkjs/conf, with `app_*` environment variables as the # by @simpleworkjs/conf (requires >= 1.2.0, pinned in nodejs/package-lock.json),
# highest-precedence override layer. This entrypoint exports those `app_*` # with `app_*` environment variables as the highest-precedence override layer.
# vars so the app connects to the bundled slapd without any mounted secrets # This entrypoint exports those `app_*` vars so the app connects to the bundled
# file. Any `app_*` var already set in the environment wins (the values below # slapd without any mounted secrets file. Any `app_*` var already set in the
# are defaults/fallbacks only). # environment wins (the values below are defaults/fallbacks only).
set -e set -e
@@ -33,14 +33,15 @@ error() { echo "[ERROR] $*" >&2; }
# ── Optional: load operational config from a mounted secrets.js ────────────── # ── Optional: load operational config from a mounted secrets.js ──────────────
# The unified theta-env stack mounts ./config/sso-secrets.js at /config and # The unified theta-env stack mounts ./config/sso-secrets.js at /config and
# treats it as the authoritative source for the SSO's config (LDAP base, admin # treats it as the authoritative source for the SSO's config (LDAP base, admin
# password, org name, JWT secret, ...). When present, symlink it into # password, org name, JWT secret, ...). When present, point CONF_SECRETS at it
# /app/conf/secrets.js so @simpleworkjs/conf reads it, and override the # so @simpleworkjs/conf reads it directly (no write access to /app/conf
# env-derived operational vars below with the file's values. When absent # needed), and override the env-derived operational vars below with the
# (standalone / env-var deployments) the env vars set above stay in effect and # file's values. When absent (standalone / env-var deployments) the env vars
# the app_* exports further down are emitted as before. # set above stay in effect and the app_* exports further down are emitted as
# before.
SECRETS_JS_MODE=0 SECRETS_JS_MODE=0
if [[ -f /config/sso-secrets.js ]]; then if [[ -f /config/sso-secrets.js ]]; then
ln -sf /config/sso-secrets.js /app/conf/secrets.js export CONF_SECRETS=/config/sso-secrets.js
SECRETS_JS_MODE=1 SECRETS_JS_MODE=1
# Pull the entrypoint's operational vars out of secrets.js in one node call. # Pull the entrypoint's operational vars out of secrets.js in one node call.
# Node emits `KEY<TAB>base64(value)` lines; we decode each with base64 -d and # Node emits `KEY<TAB>base64(value)` lines; we decode each with base64 -d and
+102
View File
@@ -0,0 +1,102 @@
---
layout: default
title: Accounts, Groups & Managers
description: A plain-language guide to users, service accounts, personal groups, and managers in SSO Manager.
---
# Accounts, Groups & Managers
This page explains the concepts behind the Users and Groups pages in plain
language. If you want the technical schema/attribute-level detail instead,
see the [LDAP reference](ldap.html).
## What's an account?
Every person (or app) that can sign in through this SSO Manager has an
**account** — a username, a display name, maybe an email address, and a
password (or, for service accounts, no password at all — see below).
Accounts live in the directory this app manages, and any other app you've
connected (Gitea, Home Assistant, your Wi-Fi, whatever) checks against these
same accounts instead of keeping its own separate list of users and
passwords.
## Two kinds of account: people and service accounts
Most accounts belong to an actual person — check **Users → People** to see
them. But sometimes you need an account for something that *isn't* a
person: a media server, a backup script, a bind account another app uses to
look people up. These are **service accounts**, listed separately under
**Users → Service Accounts**, and they're different from a person's account
in two ways that matter:
- **No email required.** A service account doesn't need a mailbox, so the
form doesn't ask for one.
- **A password is optional.** If you leave it blank, nobody can log in as
that account — which is exactly what you want for something that only
ever gets used programmatically (a script authenticating with an API
token, or another app binding with a fixed, separately-configured
password you set yourself). Only give it a password if the account
genuinely needs to log in or bind somewhere as itself.
Aside from those two differences, a service account is a completely normal
account under the hood — it can belong to groups, have a manager, and so
on, just like anyone else's.
## Groups: who can do what
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
people in the `app_sso_admin` group can see the Users/Groups/Integrations
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
photo server. Once a group exists, add or remove members from the
**Groups** page, and point the other app's "who's allowed in" setting at
that group's name.
## Every account's personal group
Separately from the groups above, every single account — person or
service account — automatically gets its own small, personal group when
it's created, named after the account itself. Most of the time you'll
never think about this; it exists so that, on a Linux system connected to
this directory, each account "owns" its own files by default the same way
a normal Unix user account would.
Occasionally you'll want to share that ownership with someone else — for
example, letting a second account also have write access to files a
service account owns. That's what the **"Members of `<uid>`'s group"**
section on a profile page is for: add another account there, and the
underlying Linux permissions treat them as if they belong to that same
personal group too.
## What's a "manager"?
Every account has one or more **managers** — the people allowed to edit
that account's profile (phone number, SSH key, home directory, and so on)
without needing full admin rights. By default, whoever created an account
(the admin who added it, or whoever sent the invite) becomes its first
manager, but you can add or remove managers later from the account's Edit
form.
This is useful for service accounts especially: if a service account
belongs to a particular project or person, make them its manager so they
can maintain it — rotate its SSH key, adjust its description — without
needing to be a full SSO administrator.
## Inviting someone vs. adding them yourself
From the Users page you can either fill in someone's details yourself
("Add new user"), or send them an **invite** — an email (or a link you copy
and send however you like) that lets them pick their own username and
password. Either way, the resulting account is identical; invites are just
a convenience so you don't have to know someone's preferred username or
handle their password directly.
## Want more detail?
This page deliberately leaves out LDAP schema names, attribute types, and
protocol-level detail. If you're connecting a third-party app directly to
the LDAP directory, or you just want to know exactly what's stored where,
see the [LDAP reference](ldap.html).
[← Back to Home](index.html)
+59
View File
@@ -0,0 +1,59 @@
---
layout: default
title: API Tokens
description: A plain-language guide to personal access tokens in SSO Manager.
---
# API Tokens
This page explains what an API token is and when you'd want one. For the
full list of API endpoints a token can call, see the
[API reference](api.html).
## What's an API token, in plain terms?
Normally, you interact with this app by logging in through a web browser.
An **API token** (also called a personal access token, or PAT) is an
alternative way in — a long, random string that a script, a scheduled job,
or another program can use instead of a username and password, to act on
your behalf without a human typing a login in each time.
If you've ever set up a script to talk to GitHub, GitLab, or a similar
service using a "token" instead of your real password, this is the same
idea.
## When would you actually need one?
Most people never need to create one of these — you'll only want a token
if you're automating something, for example:
- A script that syncs users or groups from somewhere else into this SSO
Manager on a schedule.
- A backup or monitoring job that checks this app's health via its API.
- A CI/CD pipeline that needs to register or update an OAuth client
automatically.
If you're not doing any of that, you don't need an API token — just log in
normally through the web UI.
## How it works
Create a token from your Profile page, give it a name so you remember what
it's for later, and optionally an expiry. You'll be shown the token's
value **exactly once** — copy it somewhere safe immediately, because it
can't be viewed again afterward (only revoked or rotated). Whatever script
or tool you're using it with sends it along with each request, the same
way a browser sends your login session.
A token acts **as you**, with **your** permissions — if you're not an
admin, a token you create can't do admin-only things either. If you ever
suspect a token has leaked (ended up somewhere it shouldn't have, like a
public script or log file), revoke it immediately from your Profile page;
it stops working right away.
## Want more detail?
This page doesn't attempt to list every API endpoint or show request/
response examples — for that, see the full [API reference](api.html).
[← Back to Home](index.html)
+79
View File
@@ -0,0 +1,79 @@
---
layout: default
title: Connecting Apps (Single Sign-On)
description: A plain-language guide to OAuth/OIDC clients and single sign-on in SSO Manager.
---
# Connecting Apps (Single Sign-On)
This page explains, in plain language, what happens when you "connect" an
app to your SSO Manager so people can log into it with their existing
account. For the technical endpoint/token detail, see the
[OAuth reference](oauth.html).
## What does "single sign-on" actually mean?
Instead of every app you run having its own separate list of usernames and
passwords, they all check with this SSO Manager instead. You log in once,
here, and any connected app trusts that login — no separate password to
remember or manage for each one. If you ever need to lock someone out
everywhere at once, you do it in one place (deactivate their account here)
instead of hunting down every app individually.
The technology behind this is called **OAuth 2.0** and **OpenID Connect
(OIDC)** — you'll see both names used, often together, referring to the
same thing. You don't need to understand the protocol to use this page;
what matters practically is the handful of concepts below.
## What's a "client"?
Every app you connect is registered here as a **client** — a single entry
on the Integrations page representing that one app. Registering a client
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.
You paste them into the other app's own "Single Sign-On" or "OIDC" setup
screen, along with the discovery URL shown at the top of this page, and
that app is now able to ask this SSO Manager to authenticate people on its
behalf.
**Treat the Client Secret like a password** — anyone who has it can
impersonate that app when talking to your SSO Manager. If you ever suspect
it's leaked, rotate it from the client's card.
## What are "scopes"?
**Scopes** control what information a connected app is allowed to ask for
about the person logging in — their username, email, group memberships,
and so on. Most apps tell you exactly which scopes they need in their own
setup instructions; when in doubt, the default set (`openid`, `profile`,
`email`, `groups`) covers what nearly every app expects.
## "Restrict to Groups"
By default, *any* account with an SSO Manager login can sign into a
connected app. If that's not what you want — say, a home automation
dashboard that only certain family members should reach — set **Restrict
to Groups** on that client to one of your [groups](concepts-accounts.html).
Only members of that group will be allowed to log into that particular
app; everyone else gets turned away at the login step, even though their
SSO Manager account still works everywhere else.
## Redirect URIs
A **Redirect URI** is the exact web address the connected app wants people
sent back to once they've logged in here — it's a security measure so an
attacker can't trick the login flow into redirecting somewhere else. The
app's own setup instructions will tell you this value; copy it in exactly
as given. If the app is reachable via more than one hostname (for example,
because it sits behind [theta42/proxy](https://theta42.github.io/proxy/)),
this field supports wildcard patterns — see the inline help under the
field itself for the exact syntax.
## Want more detail?
This page intentionally skips the protocol-level detail (exact endpoint
URLs, token formats, claim names). If you're troubleshooting a connection
or building something against the API directly, see the
[OAuth reference](oauth.html).
[← Back to Home](index.html)
+2
View File
@@ -32,6 +32,8 @@ raw strings otherwise.
| `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string | | `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string |
| `app_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) | | `app_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) |
| `app_ldap__uidGidReservedFloor=9000` | `conf.ldap.uidGidReservedFloor` | number (ids at/above this are ignored when allocating) | | `app_ldap__uidGidReservedFloor=9000` | `conf.ldap.uidGidReservedFloor` | number (ids at/above this are ignored when allocating) |
| `app_ldap__ldapsHost=ldap.internal.example.com` | `conf.ldap.ldapsHost` | string (hostname shown on `/integrations` for LDAPS binds; empty = derive from `oauth.issuer`) |
| `app_ldap__ldapsPort=636` | `conf.ldap.ldapsPort` | number (port shown on `/integrations`) |
| `app_oauth__jwtSecret=...` | `conf.oauth.jwtSecret` | string | | `app_oauth__jwtSecret=...` | `conf.oauth.jwtSecret` | string |
| `app_oauth__issuer=https://sso.example.com` | `conf.oauth.issuer` | string | | `app_oauth__issuer=https://sso.example.com` | `conf.oauth.issuer` | string |
| `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number | | `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number |
+92
View File
@@ -8,6 +8,10 @@ description: SSO Manager's bundled OpenLDAP directory — schema, service accoun
[← Back to Home](index.html) [← Back to Home](index.html)
> Looking for a plainer explanation of accounts, groups, and managers
> instead of schema/attribute detail? See
> [Accounts, Groups & Managers](concepts-accounts.html).
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 legacy apps that bind LDAP
@@ -57,6 +61,19 @@ membership (`memberOf` on the user); `refint` keeps it consistent on
add/remove. **Admin permission checks read the group's `member` list**, not add/remove. **Admin permission checks read the group's `member` list**, not
`memberOf` on the user. `memberOf` on the user.
### Personal groups
Every user (person or service account) also gets a **personal Unix group**
at creation — `cn=<uid>,ou=groups,<base>`, `objectClass: posixGroup` (RFC
2307), holding just `cn` and `gidNumber` (the user's primary GID). This is a
different schema than the `groupOfNames` groups above — its membership
attribute is `memberUid` (a bare username, not a DN), and unlike
`groupOfNames` it's valid with zero members. It's excluded from the
`/groups` page (which filters on `objectClass=groupOfNames`) and managed
instead from the owning user's own profile page ("Members of `<uid>`'s
group", admin-only) — add other accounts as supplementary members, e.g. to
share write access to files owned by this group.
The SSO requires three groups (seeded automatically by the entrypoint / The SSO requires three groups (seeded automatically by the entrypoint /
`install.sh`): `install.sh`):
@@ -101,6 +118,81 @@ volumes:
The entrypoint leaves existing certs untouched (idempotent). The entrypoint leaves existing certs untouched (idempotent).
## Choosing the LDAPS hostname
The `/integrations` page advertises an **LDAPS URL** for direct LDAP binds. By
default it derives that URL from the public OAuth issuer (e.g.
`https://sso.example.com``ldaps://sso.example.com:636`). That is convenient,
but it implies LDAP clients reach your directory through the same public
hostname — which usually means port-forwarding 636 through your router.
**Do not port-forward LDAPS (636) to the public internet.** LDAP simple binds
have no rate limiting and are a brute-force target. Instead, use one of these
internal-only patterns and set `conf.ldap.ldapsHost` (or
`app_ldap__ldapsHost`) so the `/integrations` page shows the right URL.
### 1. Same Docker / local network host (best for apps on this machine)
If the LDAP client runs on the same Docker network as the SSO Manager (for
example, the bundled `theta-env` stack), use the internal service name:
```
ldaps://sso-manager:636
```
In `conf/secrets.js`:
```javascript
ldap: {
ldapsHost: 'sso-manager',
ldapsPort: 636,
}
```
The proxy in theta-env already uses this internally. The bundled slapd cert
includes `sso-manager` in its SAN when `LDAP_CERT_CN` is left at its default,
so hostname verification works without extra setup.
### 2. LAN host behind your router (best for separate home-lan machines)
Create an internal-only DNS record — e.g. `ldap.internal.example.com`
`192.168.1.10` — using your router, Pi-hole, or a local `hosts` file. Then get
or generate a cert whose SAN/CN matches that internal name:
- **Let's Encrypt wildcard** (`*.internal.example.com`) works if you own the
public domain and can complete DNS-01 challenge; the record itself can stay
private/routable only inside your LAN.
- **Internal CA** is fine for a pure LAN: run a small CA, issue a cert for
`ldap.internal.example.com`, and distribute the CA cert to clients.
- **Self-signed** with `LDAP_CERT_CN=ldap.internal.example.com` also works; copy
the generated `ldap.crt` to each client and trust it.
In `conf/secrets.js`:
```javascript
ldap: {
ldapsHost: 'ldap.internal.example.com',
ldapsPort: 636,
}
```
The URL on `/integrations` becomes `ldaps://ldap.internal.example.com:636`.
### 3. Public hostname (acceptable only behind a VPN/firewall)
If a remote host must bind LDAP, put it behind a VPN (Tailscale, WireGuard,
etc.) or a tightly locked-down firewall rule. In that case the public hostname
may be appropriate, but the LDAPS port should still not be reachable from the
open internet.
### Why not just use the LDAP server's IP address?
TLS clients verify the server name against the certificate. Connecting to
`ldaps://192.168.1.10:636` with a cert issued for `*.internal.example.com`
will fail hostname verification unless you disable cert checks — which removes
most of the security benefit of LDAPS. Always use a hostname that matches the
cert.
## Service accounts ## Service accounts
A service account is a normal `posixAccount` for something that isn't a A service account is a normal `posixAccount` for something that isn't a
+4
View File
@@ -8,6 +8,10 @@ description: SSO Manager's OpenID Connect / OAuth 2.0 provider — discovery doc
[← Back to Home](index.html) [← Back to Home](index.html)
> Looking for a plainer explanation of clients/scopes/redirect URIs instead
> of endpoint-level detail? See
> [Connecting Apps (Single Sign-On)](concepts-oauth-apps.html).
SSO Manager is an **OpenID Connect / OAuth 2.0 provider**: it issues its own SSO Manager is an **OpenID Connect / OAuth 2.0 provider**: it issues its own
access, refresh, and ID tokens that your apps can consume to authenticate access, refresh, and ID tokens that your apps can consume to authenticate
users and authorize API calls. It also runs a full OpenLDAP directory, so it users and authorize API calls. It also runs a full OpenLDAP directory, so it
+213 -702
View File
@@ -1,719 +1,230 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# install.sh - Idempotent standalone installer for Theta42 SSO Manager
# For Debian/Ubuntu systems
# #
# This script: # Install / update Theta42 SSO Manager on a fresh or existing host.
# 1. Installs Node.js 20.x
# 2. Installs and configures OpenLDAP with required schemas/overlays
# 3. Deploys the SSO Manager application
# 4. Sets up systemd services
# #
# Usage: # This script is idempotent: run it to install, and re-run it to update. It
# sudo ./install.sh [OPTIONS] # installs system dependencies (Node, OpenLDAP, Redis), force-syncs the repo at
# $REPO_DIR to its remote branch, and symlinks the systemd config straight from
# the repo. Because the config is symlinked, an update is just "sync the repo +
# restart" -- the files under /etc/systemd always track the repo.
# #
# Options: # Secrets live at $SECRETS_FILE (/etc/sso-manager/secrets.js by default),
# -p, --admin-pass PASSWORD LDAP admin password (required, or set via LDAP_ADMIN_PASS env) # outside the repo checkout so they survive the hard reset below. FIRST RUN
# -b, --base-dn DN Base DN (default: dc=example,dc=com) # ONLY (no $SECRETS_FILE yet): installs and configures OpenLDAP (modules,
# -n, --org-name NAME Organization name shown in UI/email (default: SSO Manager) # overlays, custom schema, directory tree, required SSO groups -- see
# -o, --port PORT HTTP port for SSO Manager (default: 3001) # ops/ldap-setup.sh), generates an LDAP admin password + JWT secret unless
# -j, --jwt-secret SECRET JWT secret for OAuth (default: auto-generated) # given via env, and seeds $SECRETS_FILE with those values plus SMTP
# -s, --smtp-config CONFIG SMTP config as host:port:user:pass # placeholders. Edit that file (SMTP, org name, ...) and re-run this script to
# --skip-ldap Skip LDAP installation (use existing LDAP) # apply changes -- once it exists it is never touched again, and LDAP is never
# --skip-app Skip application installation (LDAP setup only) # re-bootstrapped.
# --dry-run Show what would be done without making changes
# -h, --help Show this help
# #
# Environment variables (alternative to flags): # Intended to be driven by CI/CD with no human writes on prod: the checkout is
# LDAP_ADMIN_PASS, LDAP_BASE_DN, PORT, JWT_SECRET, SMTP_* # hard-reset to origin/$BRANCH on every run, so the box deterministically
# mirrors the repo (any drift on the box is discarded).
#
# Usage: sudo ./install.sh (override with REPO_URL=, REPO_DIR=, BRANCH=,
# SECRETS_FILE=, LDAP_BASE_DN=, LDAP_ADMIN_PASS=,
# JWT_SECRET=, ORG_NAME=, PORT=, SKIP_LDAP=true)
set -euo pipefail set -euo pipefail
# Never block on an interactive git credential prompt in CI.
export GIT_TERMINAL_PROMPT=0
# Never block on an interactive debconf prompt (e.g. tzdata, pulled in as a
# dependency of redis-server/slapd on a box that's never configured it).
export DEBIAN_FRONTEND=noninteractive
# ── Defaults ────────────────────────────────────────────────────────────────── REPO_URL="${REPO_URL:-https://github.com/theta42/sso-manager-node.git}"
BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}" REPO_DIR="${REPO_DIR:-/opt/theta42/sso-manager}"
ADMIN_PASS="${LDAP_ADMIN_PASS:-}" BRANCH="${BRANCH:-master}"
NODE_MAJOR=22
SECRETS_FILE="${SECRETS_FILE:-/etc/sso-manager/secrets.js}"
LDAP_BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}"
ORG_NAME="${ORG_NAME:-SSO Manager}" ORG_NAME="${ORG_NAME:-SSO Manager}"
PORT="${PORT:-3001}" PORT="${PORT:-3001}"
JWT_SECRET="${JWT_SECRET:-}"
SMTP_HOST="${SMTP_HOST:-}"
SMTP_PORT="${SMTP_PORT:-587}"
SMTP_USER="${SMTP_USER:-}"
SMTP_PASS="${SMTP_PASS:-}"
SKIP_LDAP="${SKIP_LDAP:-false}" SKIP_LDAP="${SKIP_LDAP:-false}"
SKIP_APP="${SKIP_APP:-false}"
DRY_RUN="${DRY_RUN:-false}"
INSTALL_DIR="/opt/sso-manager" if [ "$(id -u)" -ne 0 ]; then
SYSTEMD_DIR="/etc/systemd/system" echo "This script must be run as root (try: sudo $0)" >&2
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" exit 1
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# ── Helper functions ──────────────────────────────────────────────────────────
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
dry_run() { if [[ "$DRY_RUN" == "true" ]]; then echo "[DRY-RUN] $*"; fi; }
usage() {
grep '^#' "$0" | sed 's/^# \{0,1\}//'
exit 0
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-p|--admin-pass)
ADMIN_PASS="$2"
shift 2
;;
-b|--base-dn)
BASE_DN="$2"
shift 2
;;
-n|--org-name)
ORG_NAME="$2"
shift 2
;;
-o|--port)
PORT="$2"
shift 2
;;
-j|--jwt-secret)
JWT_SECRET="$2"
shift 2
;;
-s|--smtp-config)
IFS=':' read -r SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS <<< "$2"
shift 2
;;
--skip-ldap)
SKIP_LDAP="true"
shift
;;
--skip-app)
SKIP_APP="true"
shift
;;
--dry-run)
DRY_RUN="true"
shift
;;
-h|--help)
usage
;;
*)
error "Unknown option: $1"
usage
;;
esac
done
# Validate required parameters
if [[ -z "$ADMIN_PASS" ]]; then
error "LDAP admin password is required (-p or LDAP_ADMIN_PASS env)"
exit 1
fi fi
# Generate JWT secret if not provided # Symlink $1 -> $2, replacing whatever is already at $2 (idempotent).
if [[ -z "$JWT_SECRET" ]]; then link(){
JWT_SECRET=$(openssl rand -hex 32) ln -sfn "$1" "$2"
info "Generated JWT secret: ${JWT_SECRET:0:8}..." echo "linked $2 -> $1"
}
# Read the "version" field out of a package.json without depending on Node
# being installed yet (this runs before the Node.js install step below).
pkg_version(){
sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$1" | head -1
}
# Installed version before this run touches anything, for the upgrade banner
# at the end. Empty on a fresh install (no prior checkout).
CURRENT_VERSION=""
if [ -f "$REPO_DIR/nodejs/package.json" ]; then
CURRENT_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
fi fi
# Derive the DNS domain from the base DN (dc=foo,dc=bar -> foo.bar) for email # FIRST_RUN gates OpenLDAP bootstrap + secrets seeding below -- both only ever
# sender defaults. Override with LDAP_DOMAIN if set. # happen once, the first time this script runs on a host (i.e. before
if [[ -z "${LDAP_DOMAIN:-}" ]]; then # $SECRETS_FILE exists). Every later run only updates the code.
LDAP_DOMAIN=$(echo "$BASE_DN" | sed 's/^dc=//; s/,dc=/./g') FIRST_RUN=0
[ -f "$SECRETS_FILE" ] || FIRST_RUN=1
echo "==> Base packages"
apt-get update
apt-get install -y --no-install-recommends \
build-essential redis-server \
wget gnupg ca-certificates curl git
echo "==> Node.js ${NODE_MAJOR}.x apt source"
install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor --yes -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list
echo "==> Install Node.js"
apt-get update
apt-get install -y nodejs
echo "==> Redis"
systemctl enable --now redis-server
echo "==> Repo checkout at ${REPO_DIR} (branch ${BRANCH})"
install -d "$(dirname "$REPO_DIR")"
if [ -d "$REPO_DIR/.git" ]; then
# Force the box to match the remote branch exactly. No human edits configs
# on prod, so discarding local drift is the desired, deterministic behavior.
git -C "$REPO_DIR" fetch --prune origin
git -C "$REPO_DIR" checkout -B "$BRANCH" "origin/$BRANCH"
git -C "$REPO_DIR" reset --hard "origin/$BRANCH"
git -C "$REPO_DIR" clean -fd
else
git clone --branch "$BRANCH" "$REPO_URL" "$REPO_DIR"
fi fi
# ── System checks ───────────────────────────────────────────────────────────── NEW_VERSION="$(pkg_version "$REPO_DIR/nodejs/package.json")"
check_root() {
if [[ $EUID -ne 0 ]]; then if [ "$FIRST_RUN" -eq 1 ] && [ "$SKIP_LDAP" != "true" ]; then
error "This script must be run as root (sudo)" echo "==> First run: bootstrapping OpenLDAP (base DN: ${LDAP_BASE_DN})"
exit 1 LDAP_ADMIN_PASS="${LDAP_ADMIN_PASS:-$(openssl rand -base64 24 | tr -d '=+/')}"
fi JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
} BIND_DN="cn=admin,${LDAP_BASE_DN}"
# slapd/domain wants a dotted DNS domain (e.g. "example.com"), not the raw
check_os() { # DN -- "dc=foo,dc=bar" -> "foo.bar". A malformed value here (e.g. the raw
if [[ ! -f /etc/debian_version ]]; then # DN with only the leading "dc=" stripped) makes slapd's postinst hang
error "This script is for Debian/Ubuntu systems only" # indefinitely instead of failing cleanly.
exit 1 LDAP_DOMAIN="$(echo "$LDAP_BASE_DN" | sed 's/^dc=//; s/,dc=/./g')"
fi
info "Detected $(cat /etc/os-release | grep PRETTY_NAME | cut -d'"' -f2)" if ! command -v slapd >/dev/null 2>&1; then
} debconf-set-selections <<-EOF
slapd slapd/internal/adminpw password ${LDAP_ADMIN_PASS}
# ── Package installation ────────────────────────────────────────────────────── slapd slapd/password1 password ${LDAP_ADMIN_PASS}
install_package() { slapd slapd/password2 password ${LDAP_ADMIN_PASS}
local pkg="$1" slapd slapd/domain string ${LDAP_DOMAIN}
if dpkg -l | grep -q "^ii $pkg "; then slapd shared/organization string ${ORG_NAME}
info "Package $pkg is already installed" slapd slapd/purge_database boolean true
return 0 slapd slapd/move_old_database boolean true
fi EOF
dry_run "Would install package: $pkg" apt-get install -y slapd ldap-utils
[[ "$DRY_RUN" == "true" ]] && return 0 cat > /etc/ldap/ldap.conf <<-EOF
apt-get update -qq BASE ${LDAP_BASE_DN}
apt-get install -y -qq "$pkg" URI ldap://localhost
info "Installed $pkg" EOF
} systemctl enable --now slapd
else
install_nodejs() { echo " slapd already installed -- assuming it already serves ${LDAP_BASE_DN}"
if command -v node &>/dev/null && node --version | grep -q "v20"; then fi
info "Node.js 20.x is already installed"
return 0 echo "==> Directory structure (ou=people, ou=groups)"
fi for ou in people groups; do
dry_run "Would install Node.js 20.x" dn="ou=${ou},${LDAP_BASE_DN}"
[[ "$DRY_RUN" == "true" ]] && return 0 if ldapsearch -x -D "$BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "^dn:"; then
echo " ${dn} already exists"
info "Installing Node.js 20.x..." else
# Use NodeSource repository for Node.js 20.x ldapadd -x -D "$BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost <<-EOF
apt-get update -qq dn: ${dn}
apt-get install -y -qq curl gnupg ca-certificates objectClass: organizationalUnit
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1 ou: ${ou}
apt-get install -y -qq nodejs EOF
info "Installed Node.js $(node --version)" echo " ${dn} created"
} fi
done
# ── OpenLDAP installation and configuration ───────────────────────────────────
install_openldap() { echo "==> LDAP modules, overlays, schema, policy, SSO groups"
if command -v slapd &>/dev/null; then "$REPO_DIR/ops/ldap-setup.sh" -p "$LDAP_ADMIN_PASS" -b "$LDAP_BASE_DN" -D "$BIND_DN"
info "OpenLDAP is already installed"
return 0 echo "==> Seeding ${SECRETS_FILE}"
fi install -d -m 0750 "$(dirname "$SECRETS_FILE")"
dry_run "Would install OpenLDAP" cat > "$SECRETS_FILE" <<-SECRETSEOF
[[ "$DRY_RUN" == "true" ]] && return 0 'use strict';
info "Installing OpenLDAP..." // Generated by install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ). Edit freely --
// this file is never overwritten by a later run of install.sh.
# Pre-seed debconf for non-interactive installation // LDAP admin password + JWT secret below were auto-generated; SMTP is a
debconf-set-selections << EOF // placeholder (email delivery won't work until you fill it in).
slapd slapd/internal/adminpw string $ADMIN_PASS
slapd slapd/password1 string $ADMIN_PASS module.exports = {
slapd slapd/password2 string $ADMIN_PASS port: ${PORT},
slapd slapd/domain string ${BASE_DN#dc=} name: '${ORG_NAME}',
slapd slapd/backend string MDB ldap: {
slapd shared/organization string $ORG_NAME url: 'ldap://localhost',
slapd slapd/purge_database boolean true bindDN: '${BIND_DN}',
slapd slapd/move_old_database boolean true bindPassword: '${LDAP_ADMIN_PASS}',
slapd slapd/invalid_config boolean true userBase: 'ou=people,${LDAP_BASE_DN}',
EOF groupBase: 'ou=groups,${LDAP_BASE_DN}',
},
apt-get update -qq smtp: {
apt-get install -y -qq slapd ldap-utils host: 'smtp.example.com',
port: 587,
# Configure ldap.conf secure: false,
cat > /etc/ldap/ldap.conf << LDAPCONF user: 'noreply@${LDAP_DOMAIN}',
BASE $BASE_DN pass: 'set-me',
URI ldap://localhost from: '${ORG_NAME} <noreply@${LDAP_DOMAIN}>',
LDAPCONF },
oauth: {
# Set proper permissions issuer: '',
chmod 644 /etc/ldap/ldap.conf jwtSecret: '${JWT_SECRET}',
token_lifetime: {
info "OpenLDAP installed" access_token: 3600,
} refresh_token: 2592000,
},
configure_openldap() { },
info "Configuring OpenLDAP..." };
dry_run "Would configure OpenLDAP with base DN: $BASE_DN" SECRETSEOF
[[ "$DRY_RUN" == "true" ]] && return 0 chmod 600 "$SECRETS_FILE"
echo " seeded ${SECRETS_FILE} (LDAP + JWT are live; SMTP is a placeholder)"
# Wait for slapd to be ready echo " \$EDITOR ${SECRETS_FILE}"
for i in {1..10}; do echo " then re-run this script (or: sudo systemctl restart sso-manager)"
if ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=*)" dn >/dev/null 2>&1; then elif [ "$FIRST_RUN" -eq 1 ]; then
info "OpenLDAP is ready" echo "==> SKIP_LDAP=true -- not bootstrapping OpenLDAP or seeding ${SECRETS_FILE}"
break echo " Write it yourself (see secrets.js.example) before starting sso-manager."
fi else
sleep 1 echo "==> ${SECRETS_FILE} already exists, leaving LDAP + secrets untouched"
done fi
# Detect the database DN for our suffix echo "==> Symlink systemd config from the repo"
DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \ link "$REPO_DIR/ops/systemd/sso-manager.service" /etc/systemd/system/sso-manager.service
"(&(objectClass=olcDatabaseConfig)(olcSuffix=${BASE_DN}))" dn 2>/dev/null \
| grep "^dn:" | head -1 | sed 's/^dn: //') echo "==> Node dependencies"
# Deterministic, production-only install from the lockfile. Falls back to a
if [[ -z "$DB_DN" ]]; then # plain install if the lockfile and manifest are out of step.
# Try to find any database and update its suffix ( cd "$REPO_DIR/nodejs" && { npm ci --omit=dev || npm install --omit=dev; } )
DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \
"(objectClass=olcDatabaseConfig)" dn 2>/dev/null \ echo "==> Services"
| grep "^dn:" | head -1 | sed 's/^dn: //') systemctl daemon-reload
systemctl enable --now sso-manager.service
if [[ -n "$DB_DN" ]]; then systemctl restart sso-manager.service
info "Updating database suffix to $BASE_DN"
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF echo "==> Done."
dn: $DB_DN if [ -z "$CURRENT_VERSION" ]; then
changetype: modify echo " Installed v${NEW_VERSION}."
replace: olcSuffix elif [ "$CURRENT_VERSION" = "$NEW_VERSION" ]; then
olcSuffix: $BASE_DN echo " Already up to date (v${NEW_VERSION})."
EOF else
fi echo " Updated v${CURRENT_VERSION} -> v${NEW_VERSION}."
fi fi
echo " Update later with: sudo BRANCH=${BRANCH} $0"
if [[ -z "$DB_DN" ]]; then
error "Could not detect OpenLDAP database configuration"
return 1
fi
info "Using database: $DB_DN"
# 1. Load pw-sha2 module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "pw-sha2"; then
info "Loading pw-sha2 module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: pw-sha2
EOF
else
info "pw-sha2 module already loaded"
fi
# 2. Load ppolicy module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "ppolicy"; then
info "Loading ppolicy module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: ppolicy
EOF
else
info "ppolicy module already loaded"
fi
# 3. Load memberof module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "memberof"; then
info "Loading memberof module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{1},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: memberof
EOF
else
info "memberof module already loaded"
fi
# 4. Load refint module
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "refint"; then
info "Loading refint module..."
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=module{1},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: refint
EOF
else
info "refint module already loaded"
fi
# 5. Add ppolicy overlay
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*ppolicy*)" dn 2>/dev/null | grep -qi "ppolicy"; then
info "Adding ppolicy overlay..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: olcOverlay=ppolicy,$DB_DN
objectClass: olcOverlayConfig
objectClass: olcPPolicyConfig
olcOverlay: ppolicy
olcPPolicyDefault: cn=ppolicy,ou=policies,$BASE_DN
olcPPolicyUseLockout: TRUE
EOF
else
info "ppolicy overlay already configured"
fi
# 6. Add memberof overlay
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*memberof*)" dn 2>/dev/null | grep -qi "memberof"; then
info "Adding memberof overlay..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: olcOverlay=memberof,$DB_DN
objectClass: olcConfig
objectClass: olcMemberOf
objectClass: olcOverlayConfig
objectClass: top
olcOverlay: memberof
olcMemberOfDangling: ignore
olcMemberOfRefInt: TRUE
olcMemberOfGroupOC: groupOfNames
olcMemberOfMemberAD: member
olcMemberOfMemberOfAD: memberOf
EOF
else
info "memberof overlay already configured"
fi
# 7. Add refint overlay
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*refint*)" dn 2>/dev/null | grep -qi "refint"; then
info "Adding refint overlay..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: olcOverlay=refint,$DB_DN
objectClass: olcConfig
objectClass: olcOverlayConfig
objectClass: olcRefintConfig
objectClass: top
olcOverlay: refint
olcRefintAttribute: memberof member manager owner
EOF
else
info "refint overlay already configured"
fi
# 8. Add database indexes
info "Configuring database indexes..."
for index in "mail eq,sub" "uid eq,sub" "cn eq,sub" "member eq" "uidNumber eq" "gidNumber eq"; do
attr=$(echo "$index" | cut -d' ' -f1)
types=$(echo "$index" | cut -d' ' -f2)
ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF || true
dn: $DB_DN
changetype: modify
add: olcDbIndex
olcDbIndex: $attr $types
EOF
done
# 9. Load custom theta42 schema
if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=schema,cn=config" "(olcObjectClasses=*theta42Person*)" olcObjectClasses 2>/dev/null | grep -q "theta42"; then
info "Loading custom theta42 schema..."
ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF
dn: cn=theta42,cn=schema,cn=config
objectClass: olcSchemaConfig
cn: theta42
olcAttributeTypes: ( 1.3.6.1.4.1.99999.1.1
NAME 'dateOfBirth'
DESC 'Date of birth in ISO 8601 format YYYY-MM-DD'
EQUALITY caseExactMatch
SUBSTR caseExactSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15
SINGLE-VALUE )
olcObjectClasses: ( 1.3.6.1.4.1.99999.2.1
NAME 'theta42Person'
DESC 'Theta42 SSO extended person attributes'
AUXILIARY
MAY ( dateOfBirth ) )
EOF
else
info "theta42 schema already loaded"
fi
# 10. Create base directory structure
BIND_DN="cn=admin,$BASE_DN"
# Create base DN if it doesn't exist
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$BASE_DN" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating base DN structure..."
DC_VALUE="${BASE_DN#dc=}"
DC_VALUE="${DC_VALUE%%,*}"
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: $BASE_DN
objectClass: dcObject
objectClass: organization
dc: $DC_VALUE
o: $ORG_NAME
EOF
else
info "Base DN already exists"
fi
# Create OUs
for ou in people groups policies; do
dn="ou=$ou,$BASE_DN"
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating $ou OU..."
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: ou=$ou,$BASE_DN
objectClass: organizationalUnit
ou: $ou
EOF
else
info "OU $ou already exists"
fi
done
# 11. Create default ppolicy
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "cn=ppolicy,ou=policies,$BASE_DN" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating default ppolicy..."
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: cn=ppolicy,ou=policies,$BASE_DN
objectClass: top
objectClass: organizationalRole
objectClass: pwdPolicy
cn: ppolicy
pwdAttribute: 2.5.4.35
pwdLockout: FALSE
pwdMustChange: FALSE
pwdAllowUserChange: TRUE
EOF
else
info "Default ppolicy already exists"
fi
# 12. Create required SSO groups
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
dn="cn=$group,ou=groups,$BASE_DN"
if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then
info "Creating group: $group"
ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF
dn: $dn
objectClass: groupOfNames
objectClass: top
cn: $group
description: $ORG_NAME $group group
member: $BIND_DN
EOF
else
info "Group $group already exists"
fi
done
info "OpenLDAP configuration complete"
}
# ── Application installation ──────────────────────────────────────────────────
install_app() {
info "Installing SSO Manager application..."
dry_run "Would install application to $INSTALL_DIR"
[[ "$DRY_RUN" == "true" ]] && return 0
# Create installation directory
mkdir -p "$INSTALL_DIR"
# Copy application files
info "Copying application files..."
cp -r "$SCRIPT_DIR/nodejs/"* "$INSTALL_DIR/"
# Install npm dependencies
info "Installing npm dependencies..."
cd "$INSTALL_DIR"
npm ci --only=production --quiet
# Create secrets configuration
info "Creating application configuration..."
cat > "$INSTALL_DIR/conf/secrets.js" << SECRETEOF
'use strict';
module.exports = {
port: $PORT,
ldap: {
url: 'ldap://localhost',
bindDN: 'cn=admin,$BASE_DN',
bindPassword: '$ADMIN_PASS',
userBase: 'ou=people,$BASE_DN',
groupBase: 'ou=groups,$BASE_DN',
},
smtp: {
host: '${SMTP_HOST:-localhost}',
port: ${SMTP_PORT:-587},
user: '${SMTP_USER:-}',
pass: '${SMTP_PASS:-}',
from: '${ORG_NAME} <noreply@${LDAP_DOMAIN}>',
},
voipms: {
username: '${VOIPMS_USER:-}',
password: '${VOIPMS_PASS:-}',
did: '${VOIPMS_DID:-}',
},
oauth: {
issuer: '',
jwtSecret: '$JWT_SECRET',
token_lifetime: {
access_token: 3600,
refresh_token: 2592000
}
},
};
SECRETEOF
# Create base configuration
cat > "$INSTALL_DIR/conf/base.js" << BASEEOF
'use strict';
module.exports = {
name: "$ORG_NAME",
userModel: 'ldap',
redis: {
prefix: 'sso_manager_'
},
ldap: {
url: 'ldap://localhost',
bindDN: 'cn=admin,$BASE_DN',
bindPassword: '__IN SECRETS FILE__',
userBase: 'ou=people,$BASE_DN',
groupBase: 'ou=groups,$BASE_DN',
userFilter: '(objectClass=posixAccount)',
userNameAttribute: 'uid'
},
oauth: {
issuer: '',
jwtSecret: '__in secrets file__',
token_lifetime: {
access_token: 3600,
refresh_token: 2592000
}
},
smtp: {
host: 'localhost',
port: 587,
secure: false,
from: '$ORG_NAME <noreply@$LDAP_DOMAIN>',
},
};
BASEEOF
# Set ownership
chown -R root:root "$INSTALL_DIR"
chmod -R 755 "$INSTALL_DIR"
info "Application installed to $INSTALL_DIR"
}
# ── Systemd service configuration ─────────────────────────────────────────────
install_systemd() {
info "Installing systemd service..."
dry_run "Would install systemd service"
[[ "$DRY_RUN" == "true" ]] && return 0
cat > "$SYSTEMD_DIR/sso-manager.service" << UNITEOF
[Unit]
Description=Theta42 SSO Manager
Documentation=file://$INSTALL_DIR/README.md
After=network.target slapd.service
Wants=slapd.service
[Service]
Type=simple
User=root
WorkingDirectory=$INSTALL_DIR
ExecStart=/usr/bin/node $INSTALL_DIR/bin/www
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=NODE_PORT=$PORT
# Security hardening
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
UNITEOF
systemctl daemon-reload
systemctl enable sso-manager.service
info "Systemd service installed"
}
# ── Verification ──────────────────────────────────────────────────────────────
verify_installation() {
info "Verifying installation..."
local errors=0
# Check OpenLDAP
if command -v slapd &>/dev/null; then
if systemctl is-active --quiet slapd; then
info "✓ OpenLDAP is running"
else
warn "✗ OpenLDAP is not running"
((errors++))
fi
else
warn "✗ OpenLDAP is not installed"
((errors++))
fi
# Check application
if [[ -d "$INSTALL_DIR" ]]; then
info "✓ Application is installed"
else
warn "✗ Application is not installed"
((errors++))
fi
# Check systemd service
if systemctl is-enabled --quiet sso-manager.service 2>/dev/null; then
info "✓ Systemd service is enabled"
else
warn "✗ Systemd service is not enabled"
((errors++))
fi
if [[ $errors -eq 0 ]]; then
info "Installation verified successfully"
else
warn "Installation completed with $errors issue(s)"
fi
return $errors
}
# ── Main execution ────────────────────────────────────────────────────────────
main() {
echo
echo "=============================================="
echo " Theta42 SSO Manager Installer"
echo "=============================================="
echo
echo "Configuration:"
echo " Base DN: $BASE_DN"
echo " Port: $PORT"
echo " Install dir: $INSTALL_DIR"
echo " Skip LDAP: $SKIP_LDAP"
echo " Skip App: $SKIP_APP"
echo
check_root
check_os
if [[ "$SKIP_LDAP" != "true" ]]; then
echo
info "=== Installing OpenLDAP ==="
install_openldap
configure_openldap
fi
if [[ "$SKIP_APP" != "true" ]]; then
echo
info "=== Installing SSO Manager ==="
install_nodejs
install_app
install_systemd
fi
echo
verify_installation
echo
echo "=============================================="
echo " Installation Complete!"
echo "=============================================="
echo
if [[ "$SKIP_APP" != "true" ]]; then
info "Start the service with: systemctl start sso-manager"
info "View logs with: journalctl -fu sso-manager"
info "Access the UI at: http://localhost:$PORT"
fi
if [[ "$SKIP_LDAP" != "true" ]]; then
echo
info "LDAP Configuration:"
info " Base DN: $BASE_DN"
info " Bind DN: cn=admin,$BASE_DN"
info " Admin pass: (set by you)"
echo
info "Required SSO groups created:"
info " - app_sso_admin"
info " - app_sso_invite"
info " - app_sso_oauth_admin"
fi
echo
}
main
+7
View File
@@ -22,6 +22,13 @@ module.exports = {
groupBase: 'ou=groups,dc=example,dc=com', groupBase: 'ou=groups,dc=example,dc=com',
userFilter: '(objectClass=posixAccount)', userFilter: '(objectClass=posixAccount)',
userNameAttribute: 'uid', userNameAttribute: 'uid',
// Hostname/port advertised on the /integrations page for direct-LDAP
// clients. Leave ldapsHost empty to derive it from the OAuth issuer host.
// Set it to an internal-only name (e.g. 'ldap.internal.example.com' or
// 'sso-manager' on the Docker network) so external clients don't need a
// public 636 port forward. See docs/ldap.md.
ldapsHost: '',
ldapsPort: 636,
// New users/personal groups (see addPosixAccount/addPosixGroup in // New users/personal groups (see addPosixAccount/addPosixGroup in
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin. // models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
// Existing entries >= uidGidReservedFloor are ignored when computing // Existing entries >= uidGidReservedFloor are ignored when computing
+1 -1
View File
@@ -23,7 +23,7 @@ Auth.login = async function(data){
return {user, token} return {user, token}
}catch(error){ }catch(error){
console.error("AUTH LOGIN error:", error); console.error("AUTH LOGIN error:", error.name, error.message);
throw this.errors.login(); throw this.errors.login();
} }
}; };
+1 -1
View File
@@ -58,7 +58,7 @@ Mail.sendTemplate = async function(to, template, context, from){
to, to,
mustache.render(template.subject, context), mustache.render(template.subject, context),
mustache.render(template.message, context), mustache.render(template.message, context),
from || (template.from && mustache.render(template.message, context)) from || (template.from && mustache.render(template.from, context))
) )
}; };
+30 -3
View File
@@ -4,6 +4,31 @@ 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;
// Escape a value used inside an LDAP search filter (RFC 4515).
function escapeLDAPSearchValue(val) {
return String(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). 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 new Client({ url: conf.url });
} }
@@ -19,7 +44,7 @@ async function withClient(fn) {
} }
async function getGroups(client, member){ async function getGroups(client, member){
let memberFilter = member ? `(member=${member})`: '' let memberFilter = member ? `(member=${escapeLDAPSearchValue(member)})`: ''
let groups = (await client.search(conf.groupBase, { let groups = (await client.search(conf.groupBase, {
scope: 'sub', scope: 'sub',
@@ -35,7 +60,8 @@ async function getGroups(client, member){
} }
async function addGroup(client, data){ async function addGroup(client, data){
await client.add(`cn=${data.name},${conf.groupBase}`, { const safeName = escapeLDAPDNValue(data.name);
await client.add(`cn=${safeName},${conf.groupBase}`, {
cn: data.name, cn: data.name,
member: data.owner, member: data.owner,
description: data.description, description: data.description,
@@ -139,9 +165,10 @@ Group.get = async function(data){
} }
return withClient(async (client) => { return withClient(async (client) => {
const safeName = escapeLDAPSearchValue(data.name);
let group = (await client.search(conf.groupBase, { let group = (await client.search(conf.groupBase, {
scope: 'sub', scope: 'sub',
filter: `(&(objectClass=groupOfNames)(cn=${data.name}))`, filter: `(&(objectClass=groupOfNames)(cn=${safeName}))`,
attributes: ['cn', 'description', 'member', 'owner', 'createTimestamp', 'modifyTimestamp'], attributes: ['cn', 'description', 'member', 'owner', 'createTimestamp', 'modifyTimestamp'],
})).searchEntries[0]; })).searchEntries[0];
+2 -1
View File
@@ -2,7 +2,8 @@
const Table = require('.'); const Table = require('.');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)}; const crypto = require('crypto');
const UUID = () => crypto.randomUUID();
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || { const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || {
+2 -1
View File
@@ -1,7 +1,8 @@
'use strict'; 'use strict';
const Table = require('.'); const Table = require('.');
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)}; const crypto = require('crypto');
const UUID = () => crypto.randomUUID();
// Shared base keyMap matching Token's schema so these behave as tokens // Shared base keyMap matching Token's schema so these behave as tokens
const tokenKeyMap = { const tokenKeyMap = {
+3 -2
View File
@@ -1,7 +1,8 @@
'use strict'; 'use strict';
const Table = require('.'); const Table = require('.');
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)}; const crypto = require('crypto');
const UUID = () => crypto.randomUUID();
class Token extends Table{ class Token extends Table{
@@ -110,7 +111,7 @@ class OtpToken extends Token {
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});
} }
const code = String(Math.floor(100000 + Math.random() * 900000)); const code = String(crypto.randomInt(100000, 1000000));
return this.create({uid, code, method, created_by: uid}); return this.create({uid, code, method, created_by: uid});
} }
+66 -4
View File
@@ -45,6 +45,20 @@ function escapeLDAPSearchValue(val) {
.replace(/\0/g, '\\00'); .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
// conf.uidGidReservedFloor, plus one -- or conf.uidGidMin if there are no // conf.uidGidReservedFloor, plus one -- or conf.uidGidMin if there are no
// such entries yet. Entries at/above the reserved floor (e.g. a bootstrap // such entries yet. Entries at/above the reserved floor (e.g. a bootstrap
@@ -72,7 +86,8 @@ async function addPosixGroup(client, data){
data.gidNumber = nextPosixId(groups, 'gidNumber'); data.gidNumber = nextPosixId(groups, 'gidNumber');
await client.add(`cn=${data.cn},${conf.groupBase}`, { const safeCn = escapeLDAPDNValue(data.cn);
await client.add(`cn=${safeCn},${conf.groupBase}`, {
cn: data.cn, cn: data.cn,
gidNumber: data.gidNumber, gidNumber: data.gidNumber,
objectclass: [ 'posixGroup', 'top' ] objectclass: [ 'posixGroup', 'top' ]
@@ -94,6 +109,7 @@ async function addPosixAccount(client, data){
data.uidNumber = nextPosixId(people, 'uidNumber'); data.uidNumber = nextPosixId(people, 'uidNumber');
const safeCn = escapeLDAPDNValue(data.cn);
const entry = { const entry = {
cn: data.cn, cn: data.cn,
sn: data.sn, sn: data.sn,
@@ -143,7 +159,7 @@ async function addPosixAccount(client, data){
entry.manager = [].concat(data.manager); entry.manager = [].concat(data.manager);
} }
await client.add(`cn=${data.cn},${conf.userBase}`, entry); await client.add(`cn=${safeCn},${conf.userBase}`, entry);
return data return data
@@ -171,7 +187,6 @@ async function addLdapUser(client, data){
delete data.userPassword; delete data.userPassword;
} }
console.log('addLdapUser', data)
group = await addPosixGroup(client, data); group = await addPosixGroup(client, data);
data = await addPosixAccount(client, group); data = await addPosixAccount(client, group);
@@ -794,6 +809,53 @@ User.addSSHkey = async function(data) {
return result; return result;
}; };
// Every user gets a personal Unix group of the same name at creation (see
// addPosixGroup) -- just a GID holder, cn always equal to the user's uid.
// memberUid (RFC 2307, posixGroup) is a bare username, not a DN, unlike
// groupOfNames' `member` used by app_sso_* groups in group_ldap.js.
function personalGroupDN(uid){
return `cn=${escapeLDAPDNValue(uid)},${conf.groupBase}`;
}
User.getPersonalGroupMembers = async function(uid) {
try {
return await withClient(async (client) => {
const res = await client.search(personalGroupDN(uid), {
scope: 'base',
filter: '(objectClass=posixGroup)',
attributes: ['memberUid'],
});
const entry = res.searchEntries[0];
return [].concat((entry && entry.memberUid) || []).filter(Boolean);
});
} catch(error) {
throw error;
}
};
User.addPersonalGroupMember = async function(uid, memberUid) {
await this.get(memberUid); // throws UserNotFound if the target uid doesn't exist
await withClient(async (client) => {
await client.modify(personalGroupDN(uid), [
new Change({
operation: 'add',
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
}),
]);
});
};
User.removePersonalGroupMember = async function(uid, memberUid) {
await withClient(async (client) => {
await client.modify(personalGroupDN(uid), [
new Change({
operation: 'delete',
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
}),
]);
});
};
User.invite = async function(data = {}){ User.invite = async function(data = {}){
try{ try{
let token = await InviteToken.create({ let token = await InviteToken.create({
@@ -826,7 +888,7 @@ User.login = async function(data){
return user; return user;
}catch(error){ }catch(error){
console.error("USER LOGIN error:", error); console.error("USER LOGIN error:", error.name, error.message);
throw error; throw error;
} }
}; };
+40 -11
View File
@@ -1,17 +1,17 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.1.8", "version": "1.1.17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.1.8", "version": "1.1.17",
"license": "MIT", "license": "MIT",
"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/conf": "^1.1.0", "@simpleworkjs/conf": "^1.2.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"compression": "^1.8.1", "compression": "^1.8.1",
@@ -19,7 +19,7 @@
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"extend": "^3.0.2", "extend": "^3.0.2",
"jq-repeat": "^2.1.0", "jq-repeat": "^2.2.0",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"ldapts": "^8.1.2", "ldapts": "^8.1.2",
@@ -30,7 +30,8 @@
"mustache": "^4.2.0", "mustache": "^4.2.0",
"nodemailer": "^9.0.0", "nodemailer": "^9.0.0",
"p2psub": "^0.2.0", "p2psub": "^0.2.0",
"socket.io": "^4.8.3" "socket.io": "^4.8.3",
"xss": "^1.0.15"
}, },
"devDependencies": { "devDependencies": {
"jest": "^30.4.2", "jest": "^30.4.2",
@@ -1127,9 +1128,9 @@
} }
}, },
"node_modules/@simpleworkjs/conf": { "node_modules/@simpleworkjs/conf": {
"version": "1.1.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
"integrity": "sha512-MKRQQ4JAH2tbEm87NdkmfikTT58Tyk/SFbvCC7zKja0bK6j8zYyBXTQUJ0rnvFOVEalDWd/au4AEiptOCEqgvA==", "integrity": "sha512-X4u1oRb0A0x7wzmyiIH5hPYYIFJYUXhYVe9CPX6G6INouRIeZuHlx0pthHlihiAAIc3+KqZBx18qirFN8RoJwA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"extend": "^3.0.2" "extend": "^3.0.2"
@@ -2331,6 +2332,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/component-emitter": { "node_modules/component-emitter": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
@@ -2488,6 +2495,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/cssfilter": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz",
"integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -4357,9 +4370,9 @@
} }
}, },
"node_modules/jq-repeat": { "node_modules/jq-repeat": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.1.0.tgz", "resolved": "https://registry.npmjs.org/jq-repeat/-/jq-repeat-2.2.0.tgz",
"integrity": "sha512-e1OmSWeBEHEtyOhNVysx0bnT5wd6HlZ37JZgPcGPmACJ0K9bXDPq0xOwrM1slQMSTw7FOSNDX+MD6VwvPeeZyQ==", "integrity": "sha512-OdKAQJ8SOTZzoNL/76o5+WJehXnMCoP8aXbDtZCmDh3vuGGdXfN14FkPTqLpZC5xmlv+QVfTXu/UaIRsDjVuhA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
@@ -6488,6 +6501,22 @@
} }
} }
}, },
"node_modules/xss": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
"integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==",
"license": "MIT",
"dependencies": {
"commander": "^2.20.3",
"cssfilter": "0.0.10"
},
"bin": {
"xss": "bin/xss"
},
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+5 -5
View File
@@ -1,7 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.1.8", "version": "1.1.17",
"private": true,
"author": [ "author": [
{ {
"name": "William Mantly", "name": "William Mantly",
@@ -23,7 +22,7 @@
"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/conf": "^1.1.0", "@simpleworkjs/conf": "^1.2.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"compression": "^1.8.1", "compression": "^1.8.1",
@@ -31,7 +30,7 @@
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"extend": "^3.0.2", "extend": "^3.0.2",
"jq-repeat": "^2.1.0", "jq-repeat": "^2.2.0",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"ldapts": "^8.1.2", "ldapts": "^8.1.2",
@@ -42,7 +41,8 @@
"mustache": "^4.2.0", "mustache": "^4.2.0",
"nodemailer": "^9.0.0", "nodemailer": "^9.0.0",
"p2psub": "^0.2.0", "p2psub": "^0.2.0",
"socket.io": "^4.8.3" "socket.io": "^4.8.3",
"xss": "^1.0.15"
}, },
"license": "MIT", "license": "MIT",
"repository": { "repository": {
+71 -2
View File
@@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const router = require('express').Router(); const router = require('express').Router();
const {marked} = require('marked'); const {marked} = require('marked');
const xss = require('xss');
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info'); const buildInfo = require('../utils/build_info');
const rateLimit = require('../middleware/rate_limit'); const rateLimit = require('../middleware/rate_limit');
@@ -25,6 +26,14 @@ const values = {
// back at the root DEPLOYMENT.md (see docs/deployment.md itself), which is // back at the root DEPLOYMENT.md (see docs/deployment.md itself), which is
// already covered by the "deployment" entry. // already covered by the "deployment" entry.
const DOCS = { const DOCS = {
// Plain-language "what is this and why would I use it" guides -- linked
// directly from the relevant card in the UI (see the help icon on each
// card). Each links onward to the deeper technical doc below for readers
// who want the schema/protocol-level detail.
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')},
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.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')},
deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')}, deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')},
@@ -46,24 +55,84 @@ function fixImagePaths(html) {
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/'); return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
} }
// Docs cross-link each other as "<slug>.html" (correct for the Jekyll/GitHub
// Pages build, which is what these same .md files also feed) and
// "index.html" for the docs home -- neither resolves here, where a doc lives
// at /docs/<slug> with no .html suffix. Rewrite known doc links to the
// in-app route, same idea as fixImagePaths() above. Only touches slugs that
// actually exist, so an unrelated "foo.html" link is left alone.
// Docs are also linked by their real filename stem (e.g. "concepts-accounts.html"
// for docs/concepts-accounts.md) -- the correct, working link on the Jekyll/
// GitHub Pages build, where the URL IS the filename stem. That doesn't match
// this viewer's own short slugs (DOCS keys, e.g. "accounts"), so also resolve
// by filename as a fallback -- one link written in a doc works correctly on
// both targets, rather than needing two different link forms.
const slugByFilename = Object.fromEntries(
Object.entries(DOCS).map(([slug, d]) => [path.basename(d.file, '.md'), slug])
);
function fixDocLinks(html) {
return html
.replace(/href="index\.html"/g, 'href="/docs"')
.replace(/href="([a-z0-9-]+)\.html"/g, (match, name) => {
const slug = DOCS[name] ? name : slugByFilename[name];
return slug ? `href="/docs/${slug}"` : match;
});
}
// docs/*.md files (not the repo-root README/CHANGELOG/API.md) carry Jekyll
// front matter for the GitHub Pages build and a "← Back to Home" link back
// to that site's index -- both meaningless here (this viewer has its own
// doc-list sidebar, docs_page.ejs) and, worse, marked() doesn't know front
// matter isn't regular markdown: it rendered as a garbled heading + stray
// <hr> at the top of every page. Strip both before rendering.
function stripJekyllCruft(content) {
return content
.replace(/^---\n[\s\S]*?\n---\n/, '')
.replace(/^\s*\[← Back to Home\]\([^)]*\)\s*\n/m, '');
}
router.use(rateLimit.docs); router.use(rateLimit.docs);
router.get('/', function(req, res) { router.get('/', function(req, res) {
res.render('docs_index', {...values, docs: docList}); res.render('docs_index', {...values, docs: docList});
}); });
// Plain, dependency-free line-substring search over the same allowlisted
// doc set -- no separate index to build/maintain, no new dependency, and it
// keeps working with no internet access (same reasoning as the rest of this
// route). Must be registered before the /:slug catch-all below, or "search"
// would be treated as a (nonexistent) doc slug and 404.
router.get('/search', function(req, res) {
const q = (req.query.q || '').trim();
if (!q) return res.json({results: []});
const qLower = q.toLowerCase();
const results = [];
for (const [slug, doc] of Object.entries(DOCS)) {
try {
const content = stripJekyllCruft(fs.readFileSync(doc.file, 'utf8'));
const matchLine = content.split('\n').find(line => line.toLowerCase().includes(qLower));
if (matchLine) {
results.push({slug, title: doc.title, snippet: matchLine.trim().slice(0, 200)});
}
} catch (error) { /* unreadable doc file -- skip it */ }
}
res.json({results});
});
router.get('/:slug', function(req, res, next) { router.get('/:slug', function(req, res, next) {
const doc = DOCS[req.params.slug]; const doc = DOCS[req.params.slug];
if (!doc) return next({status: 404, message: 'Doc not found'}); if (!doc) return next({status: 404, message: 'Doc not found'});
try { try {
const content = fs.readFileSync(doc.file, 'utf8'); const content = stripJekyllCruft(fs.readFileSync(doc.file, 'utf8'));
res.render('docs_page', { res.render('docs_page', {
...values, ...values,
docs: docList, docs: docList,
currentSlug: req.params.slug, currentSlug: req.params.slug,
docTitle: doc.title, docTitle: doc.title,
docHtml: fixImagePaths(marked(content)), docHtml: xss(fixDocLinks(fixImagePaths(marked(content)))),
}); });
} catch (error) { } catch (error) {
next(error); next(error);
+14 -5
View File
@@ -5,6 +5,7 @@ var express = require('express');
var router = express.Router(); var router = express.Router();
const moment = require('moment'); const moment = require('moment');
const {marked} = require('marked'); const {marked} = require('marked');
const xss = require('xss');
const {InviteToken, PasswordResetToken} = require('./../models/token'); 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');
@@ -46,7 +47,7 @@ router.get('/health', function(req, res) {
router.get('/tos', async function(req, res, next) { router.get('/tos', async function(req, res, next) {
try { try {
const tos = await Tos.getCurrent(); const tos = await Tos.getCurrent();
res.render('tos', {...values, tosHtml: marked(tos.content), tosUpdatedOnFmt: moment(tos.updated_on, 'x').format('MMMM YYYY')}); res.render('tos', {...values, tosHtml: xss(marked(tos.content)), tosUpdatedOnFmt: moment(tos.updated_on, 'x').format('MMMM YYYY')});
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -68,7 +69,7 @@ router.get('/invites', function(req, res) {
router.get('/onboarding', async function(req, res, next) { router.get('/onboarding', async function(req, res, next) {
try { try {
const tos = await Tos.getCurrent(); const tos = await Tos.getCurrent();
res.render('onboarding', {...values, tosHtml: marked(tos.content)}); res.render('onboarding', {...values, tosHtml: xss(marked(tos.content))});
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -92,7 +93,14 @@ router.get('/login', async function(req, res, next) {
// hardcoded in a doc, so they're always right for *this* deployment. // hardcoded in a doc, so they're always right for *this* deployment.
router.get('/integrations', function(req, res, next) { router.get('/integrations', function(req, res, next) {
const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, ''); const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
const ldapHost = issuer.replace(/^https?:\/\//, '').replace(/:\d+$/, ''); // The public-facing host (from the OAuth issuer). Used for OIDC links.
const issuerHost = issuer.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
// The hostname advertised for direct LDAPS binds may be a separate,
// internal-only name so admins don't have to port-forward 636 publicly.
// Defaults to the issuer host to preserve prior behavior.
const ldapsHost = (conf.ldap && conf.ldap.ldapsHost) || issuerHost;
const ldapsPort = Number((conf.ldap && conf.ldap.ldapsPort) || 636) || 636;
const userBase = (conf.ldap && conf.ldap.userBase) || 'ou=people,dc=example,dc=com'; const userBase = (conf.ldap && conf.ldap.userBase) || 'ou=people,dc=example,dc=com';
const groupBase = (conf.ldap && conf.ldap.groupBase) || 'ou=groups,dc=example,dc=com'; const groupBase = (conf.ldap && conf.ldap.groupBase) || 'ou=groups,dc=example,dc=com';
@@ -105,8 +113,9 @@ router.get('/integrations', function(req, res, next) {
...values, ...values,
issuer, issuer,
discoveryUrl: `${issuer}/.well-known/openid-configuration`, discoveryUrl: `${issuer}/.well-known/openid-configuration`,
ldapHost, ldapHost: ldapsHost,
ldapsUrl: `ldaps://${ldapHost}:636`, ldapsUrl: `ldaps://${ldapsHost}:${ldapsPort}`,
ldapsHostExplicit: !!(conf.ldap && conf.ldap.ldapsHost),
baseDn, baseDn,
userBase, userBase,
groupBase, groupBase,
Binary file not shown.
+35
View File
@@ -144,6 +144,41 @@ router.put('/:uid/active', async function(req, res, next){
} }
}); });
router.get('/:uid/group-members', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
return res.json({results: await User.getPersonalGroupMembers(req.params.uid)});
}catch(error){
next(error);
}
});
router.put('/:uid/group-member/:memberUid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
await User.addPersonalGroupMember(req.params.uid, req.params.memberUid);
return res.json({
results: true,
message: `Added ${req.params.memberUid} to ${req.params.uid}'s group`
});
}catch(error){
next(error);
}
});
router.delete('/:uid/group-member/:memberUid', async function(req, res, next){
try{
await permission.byGroup(req.user, ['app_sso_admin']);
await User.removePersonalGroupMember(req.params.uid, req.params.memberUid);
return res.json({
results: true,
message: `Removed ${req.params.memberUid} from ${req.params.uid}'s group`
});
}catch(error){
next(error);
}
});
router.put('/:uid', async function(req, res, next){ router.put('/:uid', async function(req, res, next){
try{ try{
let user; let user;
+47
View File
@@ -0,0 +1,47 @@
'use strict';
const request = require('supertest');
const app = require('../app');
const conf = require('@simpleworkjs/conf');
const ORIG_LDAP = { ...conf.ldap };
const ORIG_OAUTH = { ...(conf.oauth || {}) };
function restoreConf() {
conf.ldap = { ...conf.ldap, ...ORIG_LDAP };
conf.oauth = { ...(conf.oauth || {}), ...ORIG_OAUTH };
}
beforeEach(() => {
// Start each test from a known state; the local secrets.js may set an issuer.
conf.ldap = { ...conf.ldap, ldapsHost: '', ldapsPort: 636 };
if (conf.oauth) conf.oauth.issuer = '';
});
afterAll(() => {
restoreConf();
});
describe('GET /integrations', () => {
test('renders and derives LDAPS URL from the request host by default', async () => {
const res = await request(app)
.get('/integrations')
.set('Host', 'sso.example.com');
expect(res.status).toBe(200);
expect(res.text).toContain('ldaps://sso.example.com:636');
});
test('uses conf.ldap.ldapsHost when set', async () => {
conf.ldap = { ...conf.ldap, ldapsHost: 'ldap.internal.example.com', ldapsPort: 1636 };
const res = await request(app)
.get('/integrations')
.set('Host', 'public.example.com');
expect(res.status).toBe(200);
expect(res.text).toContain('ldaps://ldap.internal.example.com:1636');
expect(res.text).not.toContain('ldaps://public.example.com:636');
expect(res.text).toContain('Custom <code>conf.ldap.ldapsHost</code>');
});
});
+42 -1
View File
@@ -10,7 +10,12 @@
A local copy of this project's documentation, readable from the A local copy of this project's documentation, readable from the
running app -- no internet access required. running app -- no internet access required.
</p> </p>
<ul class="list-group"> <div class="input-group mb-3">
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
<input type="search" id="docs-search-input" class="form-control" placeholder="Search the docs…" oninput="docsSearch(this.value)">
</div>
<div id="docs-search-results" style="display:none"></div>
<ul id="docs-list" class="list-group">
<% docs.forEach(function(doc){ %> <% docs.forEach(function(doc){ %>
<li class="list-group-item"> <li class="list-group-item">
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a> <a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
@@ -21,4 +26,40 @@
</div> </div>
</div> </div>
</div> </div>
<script type="text/javascript">
var docsSearchTimer;
function docsSearch(q){
clearTimeout(docsSearchTimer);
docsSearchTimer = setTimeout(function(){ docsSearchRun(q); }, 200);
}
function docsSearchRun(q){
q = (q || '').trim();
var $results = $('#docs-search-results');
var $list = $('#docs-list');
if(!q){
$results.hide().empty();
$list.show();
return;
}
// Not app.api.get() -- routes/docs.js is mounted at /docs directly,
// not under /api, unlike the rest of this app's endpoints.
$.getJSON('/docs/search', {q: q}, function(data){
$list.hide();
$results.empty().show();
var hits = (data && data.results) || [];
if(!hits.length){
$results.append($('<p class="text-muted"></p>').text('No results for "' + q + '".'));
return;
}
var $ul = $('<ul class="list-group"></ul>');
hits.forEach(function(hit){
var $li = $('<li class="list-group-item"></li>');
$('<a></a>').attr('href', '/docs/' + hit.slug).text(hit.title).appendTo($li);
$('<div class="text-muted small"></div>').text(hit.snippet).appendTo($li);
$ul.append($li);
});
$results.append($ul);
});
}
</script>
<%- include('bottom') %> <%- include('bottom') %>
+2
View File
@@ -142,6 +142,7 @@
<div class="card-header"> <div class="card-header">
<i class="fa-solid fa-object-group"></i> <i class="fa-solid fa-object-group"></i>
Add new group Add new group
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="card-body"> <div class="card-body">
@@ -167,6 +168,7 @@
<h5> <h5>
<i class="fa-solid fa-arrows-down-to-people"></i> <i class="fa-solid fa-arrows-down-to-people"></i>
Group: {{ cn }} Group: {{ cn }}
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</h5> </h5>
<ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist"> <ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist">
<li class="nav-item"> <li class="nav-item">
+51
View File
@@ -250,6 +250,7 @@
<div class="card-header bg-info bg-opacity-10"> <div class="card-header bg-info bg-opacity-10">
<i class="fa-solid fa-circle-info"></i> <i class="fa-solid fa-circle-info"></i>
OpenID Connect Endpoints OpenID Connect Endpoints
<a href="/docs/oauth-apps" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="mb-2 text-muted small"> <p class="mb-2 text-muted small">
@@ -276,6 +277,7 @@
<div class="card-header"> <div class="card-header">
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus"></i>
Register OAuth Client Register OAuth Client
<a href="/docs/oauth-apps" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="card-body"> <div class="card-body">
@@ -407,10 +409,51 @@
</p> </p>
<div class="row g-3"> <div class="row g-3">
<div class="col-12">
<div class="card shadow-sm border-warning">
<div class="card-header bg-warning bg-opacity-10">
<i class="fa-solid fa-triangle-exclamation"></i>
LDAPS hostname: keep LDAP binds off the public internet
</div>
<div class="card-body">
<p class="small mb-2">
LDAPS requires a <strong>hostname</strong>, not a bare IP address, because
the TLS client verifies the server name against the certificate.
The URL below <% if (ldapsHostExplicit) { %>is set to <code><%= ldapHost %></code> from
<code>conf.ldap.ldapsHost</code>.<% } else { %>currently matches the public
OAuth issuer host — convenient, but that implies clients reach it through
your router on port 636. <strong>Do not port-forward 636 to the internet</strong>
for LDAP simple binds; instead pick an internal-only hostname and set
<code>conf.ldap.ldapsHost</code>.<% } %>
</p>
<ul class="small mb-2">
<li><strong>Same Docker/network host (recommended for the proxy or apps on this machine):</strong>
use <code>ldaps://sso-manager:636</code> (the internal service name).
Set <code>conf.ldap.ldapsHost = 'sso-manager'</code>.</li>
<li><strong>LAN host:</strong> create an internal DNS record like
<code>ldap.internal.example.com</code> → the local IP, get or generate a cert
whose SAN matches that name, and set <code>conf.ldap.ldapsHost</code>.
A wildcard for <code>*.internal.example.com</code> works well.</li>
<li><strong>Public hostname:</strong> only acceptable behind a VPN or firewall
lockdown — never exposed to the open internet.</li>
</ul>
<p class="small mb-0">
<b>Trusting the cert:</b> The bundled slapd uses a self-signed cert unless you
mount your own at <code>/etc/openldap/certs</code>. Clients must either trust
that cert, or set <code>TLS_REQCERT never</code> / <code>rejectUnauthorized: false</code>
for LAN-only use. See <a href="/docs/ldap">LDAP docs</a> for the full
runbook, including how to set <code>ldapsHost</code> in
<code>conf/secrets.js</code> or via <code>app_ldap__ldapsHost=...</code>.
</p>
</div>
</div>
</div>
<div class="col-lg-6"> <div class="col-lg-6">
<div class="card shadow-lg"> <div class="card shadow-lg">
<div class="card-header shadow"> <div class="card-header shadow">
<i class="fa-solid fa-circle-info"></i> Connection details <i class="fa-solid fa-circle-info"></i> Connection details
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="text-muted small"> <p class="text-muted small">
@@ -425,6 +468,11 @@
<input type="text" id="f-ldapsUrl" class="form-control font-monospace" readonly value="<%= ldapsUrl %>"> <input type="text" id="f-ldapsUrl" class="form-control font-monospace" readonly value="<%= ldapsUrl %>">
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-ldapsUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="copyField('f-ldapsUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div> </div>
<% if (ldapsHostExplicit) { %>
<small class="field-help text-muted d-block">
Custom <code>conf.ldap.ldapsHost</code> — override in your secrets file if this name doesn't resolve from the client.
</small>
<% } %>
</dd> </dd>
<dt class="col-sm-4">Base DN</dt> <dt class="col-sm-4">Base DN</dt>
@@ -488,6 +536,7 @@
<div class="card shadow-lg"> <div class="card shadow-lg">
<div class="card-header shadow"> <div class="card-header shadow">
<i class="fa-solid fa-terminal"></i> Set up a Linux host (ldap-client) <i class="fa-solid fa-terminal"></i> Set up a Linux host (ldap-client)
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="text-muted small"> <p class="text-muted small">
@@ -518,6 +567,8 @@
'git clone https://github.com/theta42/ldap-client.git', 'git clone https://github.com/theta42/ldap-client.git',
'cd ldap-client', 'cd ldap-client',
'cat > ldap.vars << \'EOF\'', 'cat > ldap.vars << \'EOF\'',
'# LDAPS host advertised on the Integrations page. If this is an internal-only',
'# hostname, make sure it resolves from this host and the cert SAN matches it.',
'export ldap_host="<%= ldapHost %>"', 'export ldap_host="<%= ldapHost %>"',
'export ldap_base_dn="<%= baseDn %>"', 'export ldap_base_dn="<%= baseDn %>"',
'', '',
+92 -1
View File
@@ -52,6 +52,43 @@
renderUserGroups(currentUser); renderUserGroups(currentUser);
} }
async function renderPersonalGroupMembers(user){
try{
let res = await app.api.get('user/' + user.uid + '/group-members');
$.scope.personalGroupMembers.empty();
$.scope.personalGroupMembers.push(...(res.results || []).map(uid => ({uid})));
}catch(error){
console.error('renderPersonalGroupMembers error:', error)
}
}
async function removePersonalGroupMember(memberUid, btn){
const $row = $(btn).closest('tr');
const confirmed = await app.util.actionConfirm(`Remove ${memberUid} from ${currentUser.uid}'s group?`, $row, 'warning');
if (!confirmed) return;
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; }
$.scope.personalGroupMembers.remove('uid', memberUid);
});
}
var addPersonalGroupMemberSelect;
async function addPersonalGroupMembers(btn){
const uids = addPersonalGroupMemberSelect.get();
if(!uids.length) return;
const $card = $(btn).closest('.card-body');
for(const uid of uids){
await new Promise(function(resolve){
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');
resolve();
});
});
}
addPersonalGroupMemberSelect.clear();
renderPersonalGroupMembers(currentUser);
}
async function determinUser(){ async function determinUser(){
if(location.pathname.includes('/users/')){ if(location.pathname.includes('/users/')){
let uid = location.pathname.replace('/users/', ''); let uid = location.pathname.replace('/users/', '');
@@ -122,10 +159,15 @@
renderProfile(currentUser); renderProfile(currentUser);
renderUserGroups(currentUser); renderUserGroups(currentUser);
renderPersonalGroupMembers(currentUser);
$('#personal-group-uid-label').text(currentUser.uid);
addGroupSelect = app.ui.groupSelect('#add-group-select', { addGroupSelect = app.ui.groupSelect('#add-group-select', {
name: 'groups', values: [], placeholder: 'Type a group name…', name: 'groups', values: [], placeholder: 'Type a group name…',
}); });
addPersonalGroupMemberSelect = app.ui.userSelect('#add-personal-group-member-select', {
name: 'members', values: [], placeholder: 'Type a username…',
});
// API Tokens are self-service only — never shown when an admin is // API Tokens are self-service only — never shown when an admin is
// viewing someone else's profile via /users/:uid. // viewing someone else's profile via /users/:uid.
@@ -309,6 +351,7 @@
<i class="fa-solid fa-users-viewfinder"></i> <i class="fa-solid fa-users-viewfinder"></i>
My groups My groups
<div class="float-end"> <div class="float-end">
<a href="/docs/accounts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-arrows-up-down"></i> <i class="fa-solid fa-arrows-up-down"></i>
</div> </div>
</div> </div>
@@ -348,8 +391,54 @@
</div> </div>
</div> </div>
</div> </div>
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin">
<div class="card-header shadow">
<i class="fa-solid fa-people-group"></i>
Members of <span id="personal-group-uid-label"></span>'s group
<div class="float-end">
<a href="/docs/accounts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-arrows-up-down"></i>
</div>
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div>
<div class="card-body">
<p class="text-muted small">
Every account gets a personal Unix group (its primary GID) — add
other accounts here as supplementary members (e.g. to share write
access to files owned by this group).
</p>
<div class="table-responsive">
<table class="table">
<thead>
<th>
Username
</th>
<th class="text-end"></th>
</thead>
<tbody jq-repeat="personalGroupMembers">
<tr>
<td>{{uid}}</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removePersonalGroupMember('{{uid}}', this)">
<i class="fa-solid fa-xmark"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<label class="form-label small">Add member</label>
<div class="d-flex gap-2 align-items-start">
<div id="add-personal-group-member-select" class="flex-grow-1"></div>
<button type="button" class="btn btn-outline-dark" onclick="addPersonalGroupMembers(this)">Add</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div>
<!-- Token modal (shown once on create/rotate) --> <!-- Token modal (shown once on create/rotate) -->
<div class="modal fade" id="secretModal" tabindex="-1"> <div class="modal fade" id="secretModal" tabindex="-1">
@@ -523,7 +612,9 @@
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card shadow-lg"> <div class="card shadow-lg">
<div class="card-header"><i class="fa-solid fa-plus"></i> New API Token</div> <div class="card-header"><i class="fa-solid fa-plus"></i> New API Token
<a href="/docs/api-tokens" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="card-body"> <div class="card-body">
<p class="text-muted small">A personal access token lets scripts and services call the SSO management API as you, with your permissions. Treat it like a password.</p> <p class="text-muted small">A personal access token lets scripts and services call the SSO management API as you, with your permissions. Treat it like a password.</p>
+4
View File
@@ -126,6 +126,7 @@
<i class="fas fa-user-plus"></i> <i class="fas fa-user-plus"></i>
Invite User Invite User
<span class="float-end"> <span class="float-end">
<a href="/docs/accounts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-arrows-up-down"></i> <i class="fa-solid fa-arrows-up-down"></i>
</span> </span>
</div> </div>
@@ -151,6 +152,7 @@
<i class="fas fa-user-plus"></i> <i class="fas fa-user-plus"></i>
Add new user Add new user
<small class="text-muted">(check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab)</small> <small class="text-muted">(check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab)</small>
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="card-body"> <div class="card-body">
@@ -163,6 +165,7 @@
<div class="card-header"> <div class="card-header">
<i class="fa-solid fa-users"></i> <i class="fa-solid fa-users"></i>
User List User List
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive"> <div class="table-responsive">
@@ -232,6 +235,7 @@
<i class="fa-solid fa-gears"></i> <i class="fa-solid fa-gears"></i>
Service Accounts Service Accounts
<small class="text-muted">— Unix/POSIX accounts something runs as, not a person. Create one from the People tab's "Add new user" form.</small> <small class="text-muted">— Unix/POSIX accounts something runs as, not a person. Create one from the People tab's "Add new user" form.</small>
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div> </div>
<div class="card-header actionMessage" style="display:none"></div> <div class="card-header actionMessage" style="display:none"></div>
<div class="table-responsive"> <div class="table-responsive">
+2 -2
View File
@@ -129,7 +129,7 @@ fi
# ── 3. ppolicy overlay ──────────────────────────────────────────────────────── # ── 3. ppolicy overlay ────────────────────────────────────────────────────────
info "ppolicy overlay" info "ppolicy overlay"
if config_search -b "$DB_DN" "(olcOverlay=*ppolicy*)" dn | grep -qi "^dn:.*ppolicy"; then if config_search -b "$DB_DN" "(objectClass=olcOverlayConfig)" dn | grep -qi "^dn:.*ppolicy"; then
skip "ppolicy overlay already configured on ${DB_DN}" skip "ppolicy overlay already configured on ${DB_DN}"
else else
config_add "dn: olcOverlay=ppolicy,${DB_DN} config_add "dn: olcOverlay=ppolicy,${DB_DN}
@@ -280,7 +280,7 @@ info "verifying ppolicy is active on ${DB_DN}"
VERIFY_FAILED=0 VERIFY_FAILED=0
if config_search -b "$DB_DN" "(olcOverlay=*ppolicy*)" dn | grep -qi "^dn:.*ppolicy"; then if config_search -b "$DB_DN" "(objectClass=olcOverlayConfig)" dn | grep -qi "^dn:.*ppolicy"; then
ok "ppolicy overlay is attached to the user database" ok "ppolicy overlay is attached to the user database"
else else
warn "ppolicy overlay is NOT attached to ${DB_DN} — active/inactive toggle will fail" warn "ppolicy overlay is NOT attached to ${DB_DN} — active/inactive toggle will fail"
+7 -3
View File
@@ -1,6 +1,7 @@
[Unit] [Unit]
Description=SSO NodeJS manager Service Description=Theta42 SSO Manager
After=network.target After=network.target slapd.service
Wants=slapd.service
StartLimitIntervalSec=0 StartLimitIntervalSec=0
[Service] [Service]
@@ -8,7 +9,10 @@ Type=simple
Restart=always Restart=always
RestartSec=1 RestartSec=1
User=root User=root
ExecStart=/usr/bin/env node /var/www/sso-manager-node/nodejs/bin/www WorkingDirectory=/opt/theta42/sso-manager/nodejs
Environment="NODE_ENV=production"
Environment="CONF_SECRETS=/etc/sso-manager/secrets.js"
ExecStart=/usr/bin/env node /opt/theta42/sso-manager/nodejs/bin/www
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+11 -3
View File
@@ -2,10 +2,14 @@
// Example secrets configuration file (file-based config). // Example secrets configuration file (file-based config).
// //
// Bare-metal: copy to nodejs/conf/secrets.js and fill in your values. // Bare-metal: install.sh seeds a filled-in version of this file at
// /etc/sso-manager/secrets.js on first run (LDAP + JWT already live; only
// SMTP is left as a placeholder). Only write this one by hand if you're
// skipping install.sh's LDAP bootstrap (SKIP_LDAP=true) or setting up
// manually.
// Docker / unified stack: place at ./config/sso-secrets.js and bind-mount // Docker / unified stack: place at ./config/sso-secrets.js and bind-mount
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh symlinks // ./config at /config (see docker-compose.yml); docker-entrypoint.sh points
// it into /app/conf/secrets.js so @simpleworkjs/conf reads it. // the CONF_SECRETS env var at it so @simpleworkjs/conf reads it.
// //
// Values here override conf/base.js and win over <environment>.js. `app_*` env // Values here override conf/base.js and win over <environment>.js. `app_*` env
// vars (if any are set) override this file too — so the Docker stack passes NO // vars (if any are set) override this file too — so the Docker stack passes NO
@@ -26,6 +30,10 @@ module.exports = {
bindPassword: 'your-ldap-password', bindPassword: 'your-ldap-password',
userBase: 'ou=people,dc=example,dc=com', userBase: 'ou=people,dc=example,dc=com',
groupBase: 'ou=groups,dc=example,dc=com', groupBase: 'ou=groups,dc=example,dc=com',
// ldapsHost: 'ldap.internal.example.com', // optional: hostname shown for
// direct LDAPS binds on /integrations. Leave empty to derive from the
// OAuth issuer. Set an internal-only name to avoid port-forwarding 636.
// ldapsPort: 636,
}, },
smtp: { smtp: {
host: 'smtp.example.com', host: 'smtp.example.com',