Compare commits

...

38 Commits

Author SHA1 Message Date
wmantly 6ce36b4a14 Merge pull request #90 from theta42/feature/multi-master-ldap-location
feat: N-Way Multi-Master LDAP & User Location
2026-07-21 00:14:22 -04:00
wmantly cda76d3889 docs: Add Why and When for replication and update README features 2026-07-21 00:10:25 -04:00
wmantly 2c11226793 feat: Add documentation and Sites status dashboard page 2026-07-21 00:06:59 -04:00
wmantly 80d88b083c feat: N-Way Multi-Master LDAP replication and Location property 2026-07-20 23:56:13 -04:00
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
wmantly 65b107d8ff Merge pull request #77 from theta42/fix-account-editing-bugs
Fix account-editing bugs; add editable group membership from profile
2026-07-17 11:03:05 -04:00
wmantly 5d7c0bd594 Fix account-editing bugs from real-world feedback, add editable group membership
- Edit form's Mobile Phone field was effectively required (stray validate
  attribute) -- removed.
- Service account profiles always showed the literal filler name "Service
  Account" -- hidden now, since it's not meaningful. Required computing
  isServiceAccount in User.get(), not just listDetail().
- Fresh service accounts could look uncategorized (missing from the
  Service Accounts tab, wrong isServiceAccount) for up to 5 minutes after
  creation, due to a cache-staleness race in the create route -- the user
  gets cached via User.get() before the route marks it as a service
  account. Cleared and re-fetched after marking.
- memberOf came back as a bare string instead of a one-element array for
  users in exactly one group, causing client-side permission checks to
  iterate character-by-character and incorrectly deny access -- normalized
  alongside the existing manager normalization.
- Added editable group membership on the profile page ("My groups"),
  admin-only, using the existing per-group member endpoints.

Bumps to v1.1.8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 11:00:50 -04:00
wmantly 3ad817767a Merge pull request #76 from theta42/accounts-manager-rework
Unify service accounts, add manager field, editable homeDirectory/loginShell
2026-07-17 00:35:52 -04:00
wmantly cdc5d1528c Unify service accounts to one kind, add manager field, make homeDirectory/loginShell editable
Removes the LDAP bind-only service account type in favor of a single
Unix/POSIX account model, surfaced in a new Users > Service Accounts tab.
Adds a multi-valued `manager` field to every account (defaults to the
creator, editable, and grants edit rights on the accounts a person manages
without needing app_sso_admin). homeDirectory and loginShell are now
editable from the profile edit form.

Bumps to v1.1.7.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDEx8ghuZR61pqPXc6da9C
2026-07-17 00:32:19 -04:00
wmantly 5fc65d6fb3 Merge pull request #75 from theta42/bump-1.1.6
Bump version to 1.1.6
2026-07-16 20:24:43 -04:00
wmantly ea65a85aa9 Bump version to 1.1.6; update CHANGELOG 2026-07-16 20:22:42 -04:00
wmantly f8cf68b85f Merge pull request #74 from theta42/redesign-docs-site
Redesign docs site: match the app's own look, add SEO, mobile-ready
2026-07-16 19:54:43 -04:00
wmantly cedef0ed09 Redesign docs site: match the app's own look, add SEO, mobile-ready
The GitHub Pages site used the generic jekyll-theme-cayman theme --
purple gradient hero, no site nav, no per-page SEO. Replaced with a
custom layout that mirrors the actual app UI: dark fixed navbar with
the theta42 logo, Bootstrap 5 + Font Awesome (same stack the app
uses), content in a card, dark footer matching bottom.ejs
(copyright, MIT license, GitHub, Changelog links).

- New cross-page nav (Home/Deployment/Configuration/OAuth/LDAP/
  Changelog) -- there was previously no way to get from one docs
  page to another except a single "Back to Home" link per page.
- SEO: jekyll-seo-tag + jekyll-sitemap (both GitHub-Pages-supported
  plugins, no custom build needed) -- real per-page meta description,
  Open Graph/Twitter card tags, canonical URLs, JSON-LD, sitemap.xml,
  and a robots.txt referencing it. Added a real description to every
  page's front matter (none existed before).
- Mobile: Bootstrap's responsive grid + collapsible navbar; the
  screenshot pairs in index.md (inline width="49%" for a two-up
  desktop layout) now stack to full-width below 576px instead of
  squeezing illegibly small.

Verified with a real Jekyll build (jekyll/jekyll Docker image, no
Ruby available locally) + Playwright: desktop and mobile (375px)
screenshots of the home and deployment pages, mobile nav toggle
open/close, active-link highlighting per page, zero console/page
errors, and confirmed real SEO output (meta description, OG/Twitter
tags, canonical, JSON-LD, sitemap.xml, robots.txt) via curl against
the served site.
2026-07-16 19:52:26 -04:00
53 changed files with 2248 additions and 1216 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.
+106 -1
View File
@@ -6,6 +6,101 @@ 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
### Added
- Group membership is now editable directly from a user's profile page ("My groups" -- add via a group-name picker, remove with a button per row), instead of only from each group's own card on the Groups page. Admin-only, using the existing per-group member add/remove endpoints.
### Fixed
- The Edit Profile form's Mobile Phone field had a stray `validate=":9"` making it effectively required (submission was blocked with "Please fix the form errors" if left blank) -- it was always meant to be optional, matching the "Add user" form. Removed.
- A service account's profile always showed `Name: Service Account` -- every service account has the same literal filler given/last name (a schema-satisfying placeholder, not meant to be shown), making them indistinguishable by name. The Name line is now hidden for service accounts.
- The Users page's Service Accounts tab, and a freshly-created service account's own profile, could appear empty/not-a-service-account for up to 5 minutes right after creation. Creating a user caches it via `User.get()` *before* the route handler marks it as a service account (group membership), so the cached copy had `isServiceAccount` stuck wrong until the cache TTL expired. Now cleared and re-fetched immediately after marking.
- A user belonging to exactly one LDAP group had their `memberOf` attribute returned as a bare string instead of a one-element array (ldapts's normal behavior for single-valued attributes) -- client-side permission checks (`for(let group of user.memberOf)`) would then iterate the DN character-by-character instead of once, causing pages gated on that group (e.g. Groups) to incorrectly show "You do not have permission to be here." Normalized `memberOf` to always be an array, same fix already applied to `manager`.
## [1.1.7] - 2026-07-17
### Changed
- **Service accounts unified to one kind.** Removed the LDAP bind-only service account type (the Integrations → LDAP "Service Accounts" card, and its `/api/service-account` routes) -- every service account is now a real Unix/POSIX account with a UID, created from the new **Users → Service Accounts** tab. Email and password are both optional for service accounts; a blank password means no `userPassword` is set at all (the account simply can't bind).
- **Added a `manager` field to every account.** Multi-valued (a list of usernames), defaults to whoever created the account (the admin who added it, or whoever sent the invite), and reassignable from the account's Edit form. Anyone listed as a manager can edit that account -- same fields an admin can (mobile, description, SSH key, date of birth, home directory, login shell, manager list) -- without needing `app_sso_admin`.
- `homeDirectory` and `loginShell` are now editable from the Edit Profile form (previously view-only).
## [1.1.6] - 2026-07-16
### Changed
- Redesigned the GitHub Pages docs site to match the app's own look (dark navbar/footer, Bootstrap 5, Font Awesome) instead of the generic `jekyll-theme-cayman` theme, added a real cross-page nav, SEO (`jekyll-seo-tag` + `jekyll-sitemap`, per-page descriptions, OG/Twitter tags, sitemap.xml, robots.txt), and mobile-responsive layout.
## [1.1.5] - 2026-07-16 ## [1.1.5] - 2026-07-16
### Fixed ### Fixed
@@ -49,7 +144,17 @@ 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.5...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.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.5]: https://github.com/theta42/sso-manager-node/compare/v1.1.4...v1.1.5 [1.1.5]: https://github.com/theta42/sso-manager-node/compare/v1.1.4...v1.1.5
[1.1.4]: https://github.com/theta42/sso-manager-node/compare/v1.1.3...v1.1.4 [1.1.4]: https://github.com/theta42/sso-manager-node/compare/v1.1.3...v1.1.4
[1.1.3]: https://github.com/theta42/sso-manager-node/compare/v1.1.2...v1.1.3 [1.1.3]: https://github.com/theta42/sso-manager-node/compare/v1.1.2...v1.1.3
+50 -32
View File
@@ -176,6 +176,8 @@ docker compose exec sso-manager ldapsearch -x -H ldap://localhost:389 \
| `PORT` | `3001` | host port mapped to the UI | | `PORT` | `3001` | host port mapped to the UI |
| `LDAPS_PORT` | `636` | host port mapped to LDAPS | | `LDAPS_PORT` | `636` | host port mapped to LDAPS |
| `LDAP_PORT` | `389` | uncomment the host mapping in compose to expose plain LDAP (not recommended) | | `LDAP_PORT` | `389` | uncomment the host mapping in compose to expose plain LDAP (not recommended) |
| `LDAP_SERVER_ID` | empty | Unique integer ID (e.g. 1, 2) required to enable Multi-Master replication |
| `LDAP_REPLICATION_HOSTS` | empty | Space-separated list of other sites' LDAP URLs for replication (e.g. `ldaps://site2:636`) |
Any `app_*` var may also be set directly to override any config value (see the Any `app_*` var may also be set directly to override any config value (see the
table at the top). table at the top).
@@ -188,6 +190,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 +340,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 +361,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).
--- ---
+1
View File
@@ -55,6 +55,7 @@ RUN apk add --no-cache \
openldap-overlay-ppolicy \ openldap-overlay-ppolicy \
openldap-overlay-memberof \ openldap-overlay-memberof \
openldap-overlay-refint \ openldap-overlay-refint \
openldap-overlay-syncprov \
openldap-passwd-sha2 \ openldap-passwd-sha2 \
dumb-init \ dumb-init \
bash \ bash \
+15 -13
View File
@@ -44,6 +44,7 @@ phone-home, no hosted control plane, and no per-user pricing.
drive the management API from scripts or CI, scoped to their own permissions. drive the management API from scripts or CI, scoped to their own permissions.
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run - **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
the pieces separately against your own LDAP/Redis via `app_*` env config. the pieces separately against your own LDAP/Redis via `app_*` env config.
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master OpenLDAP replication across physical sites for HA and low latency.
## Why this over the alternatives ## Why this over the alternatives
@@ -107,23 +108,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
+36
View File
@@ -0,0 +1,36 @@
services:
site1:
build: .
container_name: sso_site1
environment:
- LDAP_SERVER_ID=1
- LDAP_REPLICATION_HOSTS=ldap://site2:389
- LDAP_BASE_DN=dc=test,dc=local
- LDAP_ADMIN_PASS=secret
ports:
- "3001:3001"
- "10389:389"
volumes:
- site1-ldap:/var/lib/ldap
- site1-redis:/data
site2:
build: .
container_name: sso_site2
environment:
- LDAP_SERVER_ID=2
- LDAP_REPLICATION_HOSTS=ldap://site1:389
- LDAP_BASE_DN=dc=test,dc=local
- LDAP_ADMIN_PASS=secret
ports:
- "3002:3001"
- "20389:389"
volumes:
- site2-ldap:/var/lib/ldap
- site2-redis:/data
volumes:
site1-ldap:
site1-redis:
site2-ldap:
site2-redis:
+42 -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
@@ -124,6 +125,8 @@ include /etc/openldap/schema/theta42.schema
include /etc/openldap/schema/sudo.schema include /etc/openldap/schema/sudo.schema
include /etc/openldap/schema/openssh-lpk.schema include /etc/openldap/schema/openssh-lpk.schema
SERVER_ID_PLACEHOLDER
# Module loading (pw-sha2 provides {SSHA512} used by the app for user passwords; # Module loading (pw-sha2 provides {SSHA512} used by the app for user passwords;
# ppolicy/memberof/refint are the overlays the app depends on). On OpenLDAP 2.5+ # ppolicy/memberof/refint are the overlays the app depends on). On OpenLDAP 2.5+
# the ppolicy schema (pwdPolicy, pwdAccountLockedTime, ...) is built into # the ppolicy schema (pwdPolicy, pwdAccountLockedTime, ...) is built into
@@ -136,6 +139,7 @@ moduleload pw-sha2
moduleload ppolicy moduleload ppolicy
moduleload memberof moduleload memberof
moduleload refint moduleload refint
SYNCPROV_MODULE_PLACEHOLDER
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are # TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
# generated/mounted above. We accept clients without their own cert (the common # generated/mounted above. We accept clients without their own cert (the common
@@ -183,6 +187,8 @@ memberof-memberof-ad memberOf
overlay refint overlay refint
refint_attributes memberOf member manager owner refint_attributes memberOf member manager owner
REPLICATION_BLOCK_PLACEHOLDER
# Access controls # Access controls
access to attrs=userPassword access to attrs=userPassword
by dn="BIND_DN_PLACEHOLDER" write by dn="BIND_DN_PLACEHOLDER" write
@@ -210,6 +216,30 @@ else
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
fi fi
# ── Multi-Master Replication Configuration ──
if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
sed -i "s|^SERVER_ID_PLACEHOLDER|ServerID ${LDAP_SERVER_ID}|" /etc/openldap/slapd.conf
sed -i "s|^SYNCPROV_MODULE_PLACEHOLDER|moduleload syncprov|" /etc/openldap/slapd.conf
# Generate syncrepl blocks
REPL_BLOCK="overlay syncprov\nsyncprov-checkpoint 100 10\nsyncprov-sessionlog 100\n\n"
RID=100
for HOST in ${LDAP_REPLICATION_HOSTS}; do
RID=$((RID + 1))
REPL_BLOCK="${REPL_BLOCK}syncrepl rid=${RID}\n provider=${HOST}\n type=refreshAndPersist\n retry=\"60 +\"\n searchbase=\"${LDAP_BASE_DN}\"\n bindmethod=simple\n binddn=\"${LDAP_BIND_DN}\"\n credentials=\"${LDAP_ADMIN_PASS}\"\n\n"
done
REPL_BLOCK="${REPL_BLOCK}mirrormode on\n"
# Replace placeholder (awk is safer for multiline replacements than sed)
awk -v repl="$(printf '%b' "$REPL_BLOCK")" '{gsub(/REPLICATION_BLOCK_PLACEHOLDER/, repl)}1' /etc/openldap/slapd.conf > /etc/openldap/slapd.conf.tmp
mv /etc/openldap/slapd.conf.tmp /etc/openldap/slapd.conf
else
sed -i "/^SERVER_ID_PLACEHOLDER/d" /etc/openldap/slapd.conf
sed -i "/^SYNCPROV_MODULE_PLACEHOLDER/d" /etc/openldap/slapd.conf
sed -i "/^REPLICATION_BLOCK_PLACEHOLDER/d" /etc/openldap/slapd.conf
fi
chown ldap:ldap /etc/openldap/slapd.conf 2>/dev/null || true chown ldap:ldap /etc/openldap/slapd.conf 2>/dev/null || true
chown -R ldap:ldap /var/lib/ldap 2>/dev/null || true chown -R ldap:ldap /var/lib/ldap 2>/dev/null || true
+39 -4
View File
@@ -1,9 +1,44 @@
title: SSO Manager title: SSO Manager
description: A self-hosted OpenID Connect provider with an OpenLDAP directory and a web management UI description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI, for home labs and small businesses that want their own identity provider.
theme: jekyll-theme-cayman url: "https://theta42.github.io"
show_downloads: false baseurl: "/sso-manager-node"
logo: /assets/img/theta42.svg
lang: en_US
plugins:
- jekyll-seo-tag
- jekyll-sitemap
github: github:
repository_url: https://github.com/theta42/sso-manager-node repository_url: https://github.com/theta42/sso-manager-node
zip_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.zip zip_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.zip
tar_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.tar.gz tar_url: https://github.com/theta42/sso-manager-node/archive/refs/heads/master.tar.gz
repository_name: theta42/sso-manager-node repository_name: theta42/sso-manager-node
nav:
- title: Home
page: /
icon: fa-house
- title: Deployment
page: /deployment.html
icon: fa-server
- title: Configuration
page: /configuration.html
icon: fa-gears
- title: OAuth
page: /oauth.html
icon: fa-key
- title: LDAP
page: /ldap.html
icon: fa-address-book
- title: Changelog
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
icon: fa-list
defaults:
- scope:
path: ""
type: "pages"
values:
layout: default
image: /assets/img/theta42.svg
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/theta42.svg' | relative_url }}">
{% seo title=false %}
<title>{% if page.title %}{{ page.title }} &middot; {% endif %}{{ site.title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
</head>
<body class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
<div class="container-fluid px-3">
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
{{ site.title }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navMain">
<ul class="navbar-nav">
{% for item in site.nav %}
<li class="nav-item">
{% if item.page %}
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% else %}
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</nav>
<main class="flex-grow-1" style="margin-top: 4.5rem;">
<div class="container-fluid py-4 py-md-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8">
<div class="card shadow-lg">
<div class="card-body p-4 p-md-5 site-content">
{{ content }}
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="py-3 bg-dark text-light mt-auto">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
<span class="d-flex align-items-center gap-2">
<a href="https://theta42.com" target="_blank" rel="noopener">
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
</a>
&copy; {{ 'now' | date: '%Y' }} theta42 &middot;
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub
</a>
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-solid fa-list"></i> Changelog
</a>
</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+116
View File
@@ -0,0 +1,116 @@
/* theta42 docs site — shares the in-app dark navbar/footer + card look
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
generic Jekyll theme. */
body {
background-color: #f4f5f6;
}
.navbar-brand img {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
}
.navbar-nav .nav-link.active {
color: #fff;
font-weight: 600;
}
/* Markdown content typography, scoped to the card body so it doesn't leak
into the nav/footer. */
.site-content h1:first-child {
margin-top: 0;
}
.site-content h1,
.site-content h2,
.site-content h3 {
font-weight: 700;
}
.site-content h2 {
margin-top: 2.5rem;
padding-bottom: .4rem;
border-bottom: 1px solid #e9ecef;
}
.site-content h3 {
margin-top: 1.75rem;
}
.site-content a {
color: #a3671f;
text-decoration-color: rgba(163, 103, 31, .35);
}
.site-content a:hover {
color: #8a5a16;
}
.site-content pre {
background-color: #212529;
color: #f8f9fa;
padding: 1rem 1.25rem;
border-radius: .375rem;
overflow-x: auto;
}
.site-content code {
color: #a3671f;
background-color: #f4f0e8;
padding: .15em .4em;
border-radius: .25rem;
font-size: .875em;
}
.site-content pre code {
color: inherit;
background: none;
padding: 0;
}
.site-content table {
display: block;
overflow-x: auto;
width: 100%;
border-collapse: collapse;
margin: 1.25rem 0;
}
.site-content table th,
.site-content table td {
border: 1px solid #dee2e6;
padding: .5rem .75rem;
text-align: left;
}
.site-content table th {
background-color: #f8f9fa;
}
.site-content blockquote {
border-left: 4px solid #C59341;
padding: .5rem 1rem;
margin: 1.25rem 0;
background-color: #f8f6f1;
color: #495057;
}
.site-content img {
max-width: 100%;
height: auto;
}
/* Screenshot grids in the markdown use width="49%" inline attrs for a
two-up desktop layout -- stack them on narrow screens instead of
squeezing to illegibility. */
@media (max-width: 576px) {
.site-content img[width] {
width: 100% !important;
margin-bottom: .75rem;
}
}
.site-content hr {
margin: 2rem 0;
border-top: 1px solid #e9ecef;
}
+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
<defs>
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#C59341" />
<stop offset="20%" stop-color="#E4B869" />
<stop offset="40%" stop-color="#FBF0B9" />
<stop offset="60%" stop-color="#DFB260" />
<stop offset="80%" stop-color="#BC8837" />
<stop offset="100%" stop-color="#A36F28" />
</linearGradient>
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#FFFFFF" />
<stop offset="40%" stop-color="#F5E3B5" />
<stop offset="70%" stop-color="#D4A343" />
<stop offset="100%" stop-color="#8A5A16" />
</linearGradient>
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
</filter>
</defs>
<g filter="url(#drop-shadow)">
<g fill="url(#gold-grad)">
<path d="M 200,40
C 290,40 350,110 350,200
C 350,290 290,360 200,360
C 110,360 50,290 50,200
C 50,110 110,40 200,40 Z
M 200,75
C 130,75 88,130 88,200
C 88,270 130,325 200,325
C 270,325 312,270 312,200
C 312,130 270,75 200,75 Z"
fill-rule="evenodd" />
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
</g>
<text x="200" y="222"
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
font-size="78"
font-weight="900"
fill="url(#text-grad)"
text-anchor="middle"
letter-spacing="-2">42</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+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)
+3
View File
@@ -1,6 +1,7 @@
--- ---
layout: default layout: default
title: Configuration title: Configuration
description: SSO Manager's config layers — conf/base.js defaults, secrets.js overrides, and app_* environment variables.
--- ---
# Configuration # Configuration
@@ -31,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 |
+1
View File
@@ -1,6 +1,7 @@
--- ---
layout: default layout: default
title: Deployment title: Deployment
description: Deploying SSO Manager — the all-in-one Docker image, bare-metal install, config layers, and backups.
--- ---
# Deployment Guide # Deployment Guide
+2
View File
@@ -1,6 +1,7 @@
--- ---
layout: default layout: default
title: Home title: Home
description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI. One login for your modern apps, one LDAP directory for the rest, no phone-home.
--- ---
# SSO Manager # SSO Manager
@@ -55,6 +56,7 @@ backend, that's the niche.
Emby, …) uses LDAPS/StartTLS against the same directory. Emby, …) uses LDAPS/StartTLS against the same directory.
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or - **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
run the pieces separately via `app_*` env config. run the pieces separately via `app_*` env config.
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
## Get it ## Get it
+131 -23
View File
@@ -1,12 +1,17 @@
--- ---
layout: default layout: default
title: LDAP title: LDAP
description: SSO Manager's bundled OpenLDAP directory — schema, service accounts, TLS, and connecting third-party apps directly.
--- ---
# LDAP Directory # LDAP Directory
[← 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
@@ -34,6 +39,15 @@ User entries are `cn=<uid>,ou=people,<base>` and carry the objectClasses:
- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`). - `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`).
- `theta42Person` (custom auxiliary; `dateOfBirth`). - `theta42Person` (custom auxiliary; `dateOfBirth`).
Every user (person or service account) also carries a `manager` attribute
(the standard COSINE `manager`, `SUP distinguishedName`) — one or more DNs of
the people who created/administer that account. Set automatically to the
creator's DN on signup (whoever an admin was logged in as, or whoever sent
the invite), and reassignable later from the account's Edit form. Anyone
listed as a `manager` can edit that account (same fields an admin can:
mobile, description, SSH key, date of birth, home directory, login shell,
and the manager list itself) without needing `app_sso_admin`.
Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64), Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64),
verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the
canonical hasher; if you provision users out-of-band, hash passwords the same canonical hasher; if you provision users out-of-band, hash passwords the same
@@ -47,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`):
@@ -91,35 +118,116 @@ 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
There are two different kinds of "not a real person" account, and which one A service account is a normal `posixAccount` for something that isn't a
you want depends on what's consuming it: person: a media manager, a torrent client, a service like Emby, or a
read-only bind account an app uses to look users up — anything that needs a
real `uidNumber`/`gidNumber` to own files, or that other accounts join via a
group for write access (e.g. a `stuff_manager` group granting write rights
to a media library). There's only one kind — every account, person or
service, is a real `posixAccount` with a UID.
**LDAP bind-only** — for an app that just needs to bind LDAP to look users up Create one from the **Users → Service Accounts** tab's "Add new user" form
(its own "LDAP authentication" settings page, or the read-only account with **This is a service account** checked — it skips the birthday/
`theta42/ldap-client` binds as). Not a `posixAccount` — no `uidNumber`, no Terms-of-Service fields a real person's account needs and asks for just an
home directory, can't log into this UI. Create one from the account name. It's flagged (via membership in the `app_sso_service_account`
**Integrations → LDAP** tab's *Service Accounts* section (create, rotate group) so it's listed separately from real people and excluded from "all
password, delete). theta-env's bootstrap creates `cn=ldapclient` this same users" notification broadcasts.
way automatically, and the proxy binds as it — don't reuse the admin DN for
this.
**Unix/POSIX** — for an account something actually *runs as* on a Linux Email and password are both optional for a service account:
host: a media manager, a torrent client, a service like Emby — anything that
needs a real `uidNumber`/`gidNumber` to own files or that other accounts join
via a group for write access (e.g. a `stuff_manager` group granting write
rights to a media library). Create one from the **Users** page's "Add new
user" form with **This is a service account** checked — it skips the
birthday/Terms-of-Service fields a real person's account needs and asks for
just an account name. It's a normal `posixAccount`, just flagged (via
membership in the `app_sso_service_account` group) so it's visibly marked in
the Users list and excluded from "all users" notification broadcasts.
Either way: don't reuse the admin DN, and give it only the group memberships - No `mail` is set unless you give it one (it never needs a mailbox).
it actually needs. - Leaving the password blank is fine — no `userPassword` attribute is set at
all, and an entry with no `userPassword` simply can't bind with any
password (standard LDAP simple-bind behavior). Only set a password if the
account actually needs to authenticate as itself (e.g. a bind-only account
an app uses to look users up).
Example bind test (LDAP bind-only account): theta-env's bootstrap creates its own `cn=ldapclient` bind account directly
against LDAP (independent of this app), and the proxy binds as it — that
account won't show up in the Service Accounts tab since it isn't managed
through this app, but it keeps working unchanged.
Either way: don't reuse the admin DN, and give a service account only the
group memberships and `manager`s it actually needs.
Example bind test (a service account with a password set):
```bash ```bash
ldapsearch -x -H ldaps://sso.example.com:636 \ ldapsearch -x -H ldaps://sso.example.com:636 \
+5
View File
@@ -1,12 +1,17 @@
--- ---
layout: default layout: default
title: OAuth / OIDC title: OAuth / OIDC
description: SSO Manager's OpenID Connect / OAuth 2.0 provider — discovery document, client registration, and token endpoints.
--- ---
# OAuth 2.0 / OpenID Connect # OAuth 2.0 / OpenID Connect
[← 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
+55
View File
@@ -0,0 +1,55 @@
---
layout: default
title: Geo-Location Scaling (Replication)
---
# Geo-Location Scaling (Replication)
SSO Manager is built to be a self-contained identity provider, but if you have multiple physical sites, you may want a local copy of the directory at each site to ensure low latency and high availability.
## Why and when to use this?
- **High Availability (HA)**: If your primary site goes completely offline, your other sites can still authenticate users locally without depending on a WAN link.
- **Low Latency**: Applications at a remote site can bind directly to their local LDAP server (`localhost` or LAN IP) instead of traversing the internet to query the primary site, making logins blazing fast.
- **Independent Failure Domains**: By replicating only the LDAP directory (the source of truth) and keeping session state (Redis) independent, you prevent complex "split-brain" scenarios in the web UI. A failure at Site A won't bring down Site B.
By default, the `sso-manager` Docker container runs a single, independent OpenLDAP instance. However, you can enable **N-Way Multi-Master Replication** via environment variables.
## How it works
In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (`slapd`).
- **Reads and Writes anywhere**: A user can change their password or update their profile at Site A, Site B, or Site C.
- **Conflict Resolution**: OpenLDAP's `syncrepl` engine uses Context Sequence Numbers (CSN) to track changes. If Site A goes offline and a user changes their password at Site B, Site A will automatically pull the newest changes the moment it rejoins the cluster.
- **Independent Redis**: Session data, API Tokens, and OAuth Clients are stored in Redis. By design, Redis is NOT replicated in this geographic setup. This ensures that a failure at Site A never causes Site B's Redis to become read-only, which would break the web UI at Site B. OAuth clients must be configured per-site.
## Configuration
To enable replication, you must pass two environment variables to the `sso-manager` container:
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
### Example using `theta-env` / Docker Compose
**Site 1 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=1
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
```
**Site 2 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=2
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
```
**Site 3 (`setup.env` or `docker-compose.yml`)**
```env
LDAP_SERVER_ID=3
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
```
Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
## User Locations
When creating or editing a user, you can specify their **Location (Site)**. This maps directly to the standard LDAP `l` (localityName) attribute, allowing you to track which physical site a user belongs to natively within the directory.
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://theta42.github.io/sso-manager-node/sitemap.xml
+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
-1
View File
@@ -82,7 +82,6 @@ app.use('/api/user', middleware.auth, require('./routes/user'));
app.use('/api/token', middleware.auth, require('./routes/token')); app.use('/api/token', middleware.auth, require('./routes/token'));
app.use('/api/group', middleware.auth, require('./routes/group')); app.use('/api/group', middleware.auth, require('./routes/group'));
app.use('/api/service-account', middleware.auth, require('./routes/service_account'));
app.use('/api/notification', middleware.auth, require('./routes/notification')); app.use('/api/notification', middleware.auth, require('./routes/notification'));
app.use('/api/update-check', middleware.auth, require('./routes/update_check')); app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
app.use('/api/tos', middleware.auth, require('./routes/tos')); app.use('/api/tos', middleware.auth, require('./routes/tos'));
+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 = {
-114
View File
@@ -1,114 +0,0 @@
'use strict';
// Non-person "service" accounts under ou=people -- bind-only LDAP identities
// for things like theta-env's bootstrap-created cn=ldapclient (the proxy's
// direct-LDAP bind account) or any other app/host that needs its own
// dedicated read-only credential, as opposed to a real user who logs into
// the web UI.
//
// Deliberately NOT posixAccount/inetOrgPerson (the User model's shape) --
// these can't log into the SSO Manager UI or get a home directory/uidNumber.
// objectClass matches exactly what theta-env's bootstrap.js already creates
// for cn=ldapclient, so this model recognizes and manages that account too,
// not just ones created through this UI.
const { Client, Attribute, Change } = require('ldapts');
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf').ldap;
function hashPasswordSSHA512(password) {
const salt = crypto.randomBytes(8);
const hash = crypto.createHash('sha512').update(password).update(salt).digest();
return '{SSHA512}' + Buffer.concat([hash, salt]).toString('base64');
}
function makeClient() {
return new Client({ url: conf.url });
}
async function withClient(fn) {
const client = makeClient();
try {
await client.bind(conf.bindDN, conf.bindPassword);
return await fn(client);
} finally {
await client.unbind().catch(() => {});
}
}
const FILTER = '(&(objectClass=organizationalRole)(objectClass=simpleSecurityObject))';
const CN_RE = /^[A-Za-z][A-Za-z0-9._-]{1,63}$/;
var ServiceAccount = {};
ServiceAccount.list = async function(){
return withClient(async (client) => {
const res = await client.search(conf.userBase, {
scope: 'sub',
filter: FILTER,
attributes: ['cn', 'description', 'createTimestamp', 'modifyTimestamp'],
});
return res.searchEntries.map((entry) => ({
cn: entry.cn,
dn: `cn=${entry.cn},${conf.userBase}`,
description: entry.description || '',
created_on: entry.createTimestamp || null,
modified_on: entry.modifyTimestamp || null,
})).sort((a, b) => a.cn.localeCompare(b.cn));
});
};
ServiceAccount.create = async function({cn, description}){
if(!cn || !CN_RE.test(cn)){
throw Object.assign(new Error('InvalidName'), {status: 400, message: 'Name must start with a letter and contain only letters, numbers, dot, dash, underscore.'});
}
const dn = `cn=${cn},${conf.userBase}`;
const password = crypto.randomBytes(24).toString('base64url');
await withClient(async (client) => {
let existing = true;
try{
const res = await client.search(dn, {scope: 'base', filter: '(objectClass=*)', attributes: ['dn']});
existing = res.searchEntries.length > 0;
}catch(error){ existing = false; }
if(existing){
throw Object.assign(new Error('NameInUse'), {status: 409, message: `"${cn}" already exists under ${conf.userBase}.`});
}
await client.add(dn, {
objectClass: ['organizationalRole', 'simpleSecurityObject', 'top'],
cn,
description: description || '',
userPassword: hashPasswordSSHA512(password),
});
});
return {cn, dn, description: description || '', password};
};
ServiceAccount.setPassword = async function(cn, password){
const dn = `cn=${cn},${conf.userBase}`;
const newPassword = password || crypto.randomBytes(24).toString('base64url');
await withClient(async (client) => {
await client.modify(dn, [
new Change({
operation: 'replace',
modification: new Attribute({type: 'userPassword', values: [hashPasswordSSHA512(newPassword)]}),
}),
]);
});
return {cn, dn, password: newPassword};
};
ServiceAccount.remove = async function(cn){
const dn = `cn=${cn},${conf.userBase}`;
await withClient(async (client) => {
await client.del(dn);
});
return true;
};
module.exports = {ServiceAccount};
+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});
} }
+141 -9
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,
@@ -103,7 +119,6 @@ async function addPosixAccount(client, data){
givenName: data.givenName, givenName: data.givenName,
loginShell: data.loginShell, loginShell: data.loginShell,
homeDirectory: data.homeDirectory, homeDirectory: data.homeDirectory,
userPassword: data.userPassword,
description: data.description || ' ', description: data.description || ' ',
sudoHost: 'ALL', sudoHost: 'ALL',
sudoCommand: 'ALL', sudoCommand: 'ALL',
@@ -131,7 +146,24 @@ async function addPosixAccount(client, data){
entry.dateOfBirth = data.dob; entry.dateOfBirth = data.dob;
} }
await client.add(`cn=${data.cn},${conf.userBase}`, entry); if (data.location) {
entry.l = data.location;
}
// userPassword is optional -- a service account with no password set
// simply can't bind (no special enforcement needed, that's the default
// LDAP simple-bind behavior for an entry lacking the attribute).
if (data.userPassword) {
entry.userPassword = data.userPassword;
}
// manager (COSINE, SUP distinguishedName) is naturally multi-valued --
// every account gets at least the DN of whoever created it.
if (data.manager && [].concat(data.manager).length) {
entry.manager = [].concat(data.manager);
}
await client.add(`cn=${safeCn},${conf.userBase}`, entry);
return data return data
@@ -151,11 +183,14 @@ async function addLdapUser(client, data){
data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase(); data.uid = `${data.givenName[0]}${data.sn}`.toLowerCase();
} }
data.cn = data.uid; data.cn = data.uid;
data.loginShell = '/bin/bash'; data.loginShell = data.loginShell || '/bin/bash';
data.homeDirectory= `/home/${data.uid}`; data.homeDirectory = data.homeDirectory || `/home/${data.uid}`;
data.userPassword = hashPasswordSSHA512(data.userPassword); if (data.userPassword) {
data.userPassword = hashPasswordSSHA512(data.userPassword);
} else {
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);
@@ -190,10 +225,19 @@ const user_parse = function(data){
data.username = data[conf.userNameAttribute] data.username = data[conf.userNameAttribute]
data.userPassword = undefined; data.userPassword = undefined;
} }
data.location = data.l ? String(data.l) : '';
// Use truthy strings so jq-repeat section blocks ({{#isActive}}) fire correctly // Use truthy strings so jq-repeat section blocks ({{#isActive}}) fire correctly
data.isActive = data.pwdAccountLockedTime ? '' : 'active'; data.isActive = data.pwdAccountLockedTime ? '' : 'active';
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : ''; data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
// manager (COSINE, SUP distinguishedName) and memberOf (from the memberof
// overlay) are both multi-valued; ldapts returns a bare string for a
// single value and an array for multiple -- normalize both to always be
// an array, or app-base.js's `for(let group of user.memberOf)` silently
// iterates a single DN string character-by-character instead of once.
data.manager = [].concat(data.manager || []).filter(Boolean);
data.memberOf = [].concat(data.memberOf || []).filter(Boolean);
return data; return data;
} }
@@ -242,6 +286,8 @@ User.listDetail = async function(){
serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase())); serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
}catch(error){ /* group not seeded yet on an old deployment -- treat as none */ } }catch(error){ /* group not seeded yet on an old deployment -- treat as none */ }
const dnToUid = new Map(searchEntries.map(e => [String(e.dn).toLowerCase(), e.uid]));
const users = await Promise.all(searchEntries.map(async (entry) => { const users = await Promise.all(searchEntries.map(async (entry) => {
const rawPassword = entry.userPassword ? entry.userPassword.toString() : ''; const rawPassword = entry.userPassword ? entry.userPassword.toString() : '';
const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}'); const isLegacyMD5 = rawPassword.toUpperCase().startsWith('{MD5}');
@@ -269,6 +315,7 @@ User.listDetail = async function(){
].filter(Boolean); ].filter(Boolean);
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : ''; obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : ''; obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
return obj; return obj;
})); }));
@@ -324,6 +371,13 @@ User.get = async function(data, key) {
const verif = await UserVerification.getOrCreate(obj.uid); const verif = await UserVerification.getOrCreate(obj.uid);
// Same membership check as User.listDetail() -- see the comment there.
try{
const svcGroup = await Group.get('app_sso_service_account');
const serviceAccountDNs = new Set((svcGroup.member || []).map(dn => dn.toLowerCase()));
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
}catch(error){ obj.isServiceAccount = ''; }
// Auto-flag legacy MD5 password users — persist so subsequent cache hits see it // Auto-flag legacy MD5 password users — persist so subsequent cache hits see it
if (isLegacyMD5 && !verif.password_must_change) { if (isLegacyMD5 && !verif.password_must_change) {
await verif.update({ password_must_change: true }); await verif.update({ password_must_change: true });
@@ -421,7 +475,7 @@ User.update = async function(data){
} }
} }
let editableFeilds = ['mobile', 'description']; let editableFeilds = ['mobile', 'description', 'homeDirectory', 'loginShell'];
await withClient(async (client) => { await withClient(async (client) => {
for(let field of editableFeilds){ for(let field of editableFeilds){
@@ -469,6 +523,31 @@ User.update = async function(data){
]); ]);
this.dateOfBirth = data.dateOfBirth; this.dateOfBirth = data.dateOfBirth;
} }
if(data.location !== undefined){
await client.modify(this.dn, [
new Change({
operation: 'replace',
modification: new Attribute({ type: 'l', values: [data.location] }),
}),
]);
this.location = data.location;
}
if(data.manager !== undefined){
// Client sends uids; resolve each to a DN before writing --
// manager (COSINE, SUP distinguishedName) stores DNs, not uids.
const uids = [].concat(data.manager || []).filter(Boolean);
const managers = await Promise.all(uids.map(uid => User.get(uid)));
const dns = managers.map(u => u.dn);
await client.modify(this.dn, [
new Change({
operation: 'replace',
modification: new Attribute({ type: 'manager', values: dns }),
}),
]);
this.manager = dns;
}
}); });
cache.clear(); cache.clear();
@@ -537,6 +616,12 @@ User.addByInvite = async function(data){
data.mail = token.mail; data.mail = token.mail;
// Default manager: whoever sent the invite.
try {
const inviter = await this.get(token.created_by);
data.manager = [inviter.dn];
} catch(e) { /* inviter no longer exists -- leave manager unset */ }
const suggestions = await this.usernameSuggestions(data.givenName, data.sn, data.dob); const suggestions = await this.usernameSuggestions(data.givenName, data.sn, data.dob);
if (!data.uid || !suggestions.includes(data.uid)) { if (!data.uid || !suggestions.includes(data.uid)) {
const err = new Error('Invalid username selection'); const err = new Error('Invalid username selection');
@@ -739,6 +824,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({
@@ -771,7 +903,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.5", "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.5", "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.5", "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": {
+43 -2
View File
@@ -102,7 +102,15 @@ app.user = (function(app){
}); });
} }
return {list, remove, createInvite, setActive}; // A user DN's cn is always their uid (see models/user_ldap.js addLdapUser,
// `data.cn = data.uid`) -- pulling it straight out of the DN avoids an
// extra lookup just to display a manager list.
function dnToUid(dn){
var m = /^cn=([^,]+)/i.exec(dn || '');
return m ? m[1] : dn;
}
return {list, remove, createInvite, setActive, dnToUid};
})(app); })(app);
@@ -149,6 +157,21 @@ app.ui = (function(app){
// Drop the cache (e.g. after a group is created) so the next selector refetches. // Drop the cache (e.g. after a group is created) so the next selector refetches.
function refreshGroups(){ _groupsPromise = null; return loadGroups(); } function refreshGroups(){ _groupsPromise = null; return loadGroups(); }
// All usernames, fetched once and shared across every user selector (e.g. manager pickers).
var _usersPromise = null;
function loadUsers(){
if(!_usersPromise){
_usersPromise = new Promise(function(resolve){
app.user.list(function(error, data){
if(error || !data || !data.results){ resolve([]); return; }
resolve(data.results.map(function(u){ return u.uid; }).filter(Boolean).sort());
});
});
}
return _usersPromise;
}
function refreshUsers(){ _usersPromise = null; return loadUsers(); }
// opts: { values, options, freeSolo, placeholder, name, separator } // opts: { values, options, freeSolo, placeholder, name, separator }
// Returns a handle: { get, set, add, clear, setOptions, element }. // Returns a handle: { get, set, add, clear, setOptions, element }.
function tagInput(mount, opts){ function tagInput(mount, opts){
@@ -249,7 +272,25 @@ app.ui = (function(app){
return handle; return handle;
} }
return { tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups }; // Universal user selector (e.g. picking managers). Preloads all usernames.
function userSelect(mount, opts){
opts = opts || {};
var handle = tagInput(mount, {
name: opts.name || 'manager',
values: opts.values || [],
options: [],
freeSolo: opts.freeSolo !== false,
separator: opts.separator != null ? opts.separator : '\n',
placeholder: opts.placeholder || 'Type a username…',
});
loadUsers().then(function(users){ handle.setOptions(users); });
return handle;
}
return {
tagInput: tagInput, groupSelect: groupSelect, loadGroups: loadGroups, refreshGroups: refreshGroups,
userSelect: userSelect, loadUsers: loadUsers, refreshUsers: refreshUsers,
};
})(app); })(app);
app.oauthClient = (function(app){ app.oauthClient = (function(app){
+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);
+53 -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,
@@ -136,6 +145,45 @@ router.get('/token', function(req, res, next) {
res.render('token', {...values}); res.render('token', {...values});
}); });
router.get('/sites', async function(req, res, next) {
const net = require('net');
const url = require('url');
const myId = process.env.LDAP_SERVER_ID || 'Standalone';
const hostsStr = process.env.LDAP_REPLICATION_HOSTS || '';
const hosts = hostsStr.split(' ').filter(h => h);
const sites = await Promise.all(hosts.map(hostUrl => {
return new Promise((resolve) => {
try {
const u = new url.URL(hostUrl);
const port = u.port || (u.protocol === 'ldaps:' ? 636 : 389);
const hostname = u.hostname;
const socket = new net.Socket();
socket.setTimeout(2000);
socket.on('connect', () => {
socket.destroy();
resolve({ url: hostUrl, status: 'Online' });
});
socket.on('timeout', () => {
socket.destroy();
resolve({ url: hostUrl, status: 'Offline (Timeout)' });
});
socket.on('error', (err) => {
socket.destroy();
resolve({ url: hostUrl, status: 'Offline (' + err.code + ')' });
});
socket.connect(port, hostname);
} catch (e) {
resolve({ url: hostUrl, status: 'Invalid URL' });
}
});
}));
res.render('sites', { ...values, myId, sites });
});
router.get('/login/resetpassword/:token', async function(req, res, next){ router.get('/login/resetpassword/:token', async function(req, res, next){
let token = await PasswordResetToken.get(req.params.token); let token = await PasswordResetToken.get(req.params.token);
Binary file not shown.
-54
View File
@@ -1,54 +0,0 @@
'use strict';
const router = require('express').Router();
const {ServiceAccount} = require('../models/service_account');
const permission = require('../utils/permission');
const ADMIN_GROUP = 'app_sso_admin';
router.get('/', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
return res.json({results: await ServiceAccount.list()});
} catch(error) {
next(error);
}
});
router.post('/', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const result = await ServiceAccount.create({cn: req.body.cn, description: req.body.description});
return res.json({
results: result,
message: `Service account "${result.cn}" created. Save the password now — it will not be shown again.`,
});
} catch(error) {
next(error);
}
});
router.put('/:cn/password', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const result = await ServiceAccount.setPassword(req.params.cn, req.body.password);
return res.json({
results: result,
message: `Password rotated for "${req.params.cn}". Save it now — it will not be shown again.`,
});
} catch(error) {
next(error);
}
});
router.delete('/:cn', async function(req, res, next) {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
await ServiceAccount.remove(req.params.cn);
return res.json({message: `Service account "${req.params.cn}" deleted.`});
} catch(error) {
next(error);
}
});
module.exports = router;
+52 -2
View File
@@ -23,8 +23,9 @@ router.post('/', async function(req, res, next){
await permission.byGroup(req.user, ['app_sso_admin']) await permission.byGroup(req.user, ['app_sso_admin'])
req.body.created_by = req.user.uid req.body.created_by = req.user.uid
req.body.manager = [req.user.dn];
const user = await User.add(req.body); let user = await User.add(req.body);
const verif = await UserVerification.getOrCreate(user.uid); const verif = await UserVerification.getOrCreate(user.uid);
const updates = { password_must_change: true }; const updates = { password_must_change: true };
if (req.body.tosAgree) updates.tos_accepted = true, updates.tos_accepted_at = Date.now(); if (req.body.tosAgree) updates.tos_accepted = true, updates.tos_accepted_at = Date.now();
@@ -37,6 +38,12 @@ router.post('/', async function(req, res, next){
try { try {
const group = await Group.get('app_sso_service_account'); const group = await Group.get('app_sso_service_account');
await group.addMember(user); await group.addMember(user);
// User.add() already cached `user` (via its own internal
// User.get()) before this group membership existed, so the
// cached isServiceAccount would be stuck wrong for 5 minutes
// (the cache TTL) without this -- re-fetch after clearing.
User.clearCache();
user = await User.get(user.uid);
} catch (error) { } catch (error) {
console.error(`user.add: failed to mark ${user.uid} as a service account:`, error.message); console.error(`user.add: failed to mark ${user.uid} as a service account:`, error.message);
} }
@@ -137,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;
@@ -145,7 +187,15 @@ router.put('/:uid', async function(req, res, next){
user = req.user; user = req.user;
}else{ }else{
user = await User.get(req.params.uid); user = await User.get(req.params.uid);
await permission.byGroup(req.user, ['app_sso_admin']) const isManager = (user.manager || []).includes(req.user.dn);
if(!isManager) await permission.byGroup(req.user, ['app_sso_admin'])
}
// The manager picker is a tag widget backed by a single newline-separated
// hidden input (see public/js/app.js app.ui.userSelect), same convention
// as oauth_client.js's allowed_groups.
if (typeof req.body.manager === 'string') {
req.body.manager = req.body.manager.split('\n').map(s => s.trim()).filter(Boolean);
} }
return res.json({ return res.json({
+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">
+60 -101
View File
@@ -208,40 +208,8 @@
}); });
} }
// ── Service accounts ──────────────────────────────────────────────────
async function svcTableAJAX(){
let data = await app.api.get('service-account');
$.scope.serviceAccountCard.empty();
$.each(data.results, function(_, acct){
$.scope.serviceAccountCard.push(acct);
});
}
async function rotateServiceAccountPassword(cn, btn){
const $card = $(btn).closest('.card');
const confirmed = await app.util.actionConfirm('Rotate the password for "' + cn + '"? Anything still using the old password will stop working immediately.', $card, 'warning');
if (!confirmed) return;
app.api.put('service-account/' + encodeURIComponent(cn) + '/password', {}, function(error, data){
if(error){ app.util.actionMessage('Error: ' + (data && data.message), $card, 'danger'); return; }
showSecret(data.results.password, 'Password for ' + cn);
});
}
async function deleteServiceAccount(cn, btn){
const $card = $(btn).closest('.card');
$card.addClass('table-warning');
const confirmed = await app.util.actionConfirm('Delete service account "' + cn + '"? Anything binding as it will stop working immediately.', $card, 'warning');
$card.removeClass('table-warning');
if (!confirmed) return;
app.api.delete('service-account/' + encodeURIComponent(cn), function(error, data){
if(error){ app.util.actionMessage('Error: ' + (data && data.message), $card, 'danger'); return; }
$.scope.serviceAccountCard.remove('cn', cn);
});
}
$(document).ready(function(){ $(document).ready(function(){
tableAJAX(); tableAJAX();
svcTableAJAX();
// Initialise the create-form tag widgets. // Initialise the create-form tag widgets.
createScopes = app.ui.tagInput('#create-scopes', { createScopes = app.ui.tagInput('#create-scopes', {
@@ -256,9 +224,6 @@
$('form[action="oauth/client/"]').attr('evalAJAX', $('form[action="oauth/client/"]').attr('evalAJAX',
'showSecret(data.client_secret, "Client Secret"); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();' 'showSecret(data.client_secret, "Client Secret"); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();'
); );
$('form[action="service-account/"]').attr('evalAJAX',
'showSecret(data.password, "Password for " + data.cn); svcTableAJAX(); $form.trigger("reset");'
);
}); });
</script> </script>
@@ -285,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">
@@ -311,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">
@@ -442,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">
@@ -460,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>
@@ -509,8 +522,9 @@
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-bindDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="copyField('f-bindDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
</div> </div>
<small class="field-help text-muted d-block"> <small class="field-help text-muted d-block">
A read-only bind account — create one below under A read-only bind account — create one from
<b>Service Accounts</b> (don't reuse a real person's login or the admin DN). <a href="/users">Users &gt; Service Accounts</a> (don't reuse a real
person's login or the admin DN).
</small> </small>
</dd> </dd>
</dl> </dl>
@@ -522,14 +536,16 @@
<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">
For full host login, SSH keys, and sudo via LDAP (not just one app) — For full host login, SSH keys, and sudo via LDAP (not just one app) —
clone <a href="https://github.com/theta42/ldap-client" target="_blank">theta42/ldap-client</a> clone <a href="https://github.com/theta42/ldap-client" target="_blank">theta42/ldap-client</a>
and run this on the host. Fill in a service account's password (create and run this on the host. Fill in a service account's password
one below) and, if you want this host's access/sudo groups (create one from <a href="/users">Users &gt; Service Accounts</a>) and,
auto-registered, an <a href="/">API token</a> from your Profile. if you want this host's access/sudo groups auto-registered, an
<a href="/">API token</a> from your Profile.
</p> </p>
<div class="input-group"> <div class="input-group">
<textarea id="f-bashSnippet" class="form-control font-monospace" rows="16" readonly style="font-size:.8rem"></textarea> <textarea id="f-bashSnippet" class="form-control font-monospace" rows="16" readonly style="font-size:.8rem"></textarea>
@@ -541,65 +557,6 @@
</div> </div>
</div> </div>
<div class="col-12">
<div class="card shadow-sm border-info">
<div class="card-header bg-info bg-opacity-10">
<i class="fa-solid fa-user-gear"></i> Service Accounts
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Bind-only LDAP identities for apps and hosts — not real people, can't log
into this UI, no home directory. theta-env's <code>cn=ldapclient</code>
bootstrap account (used by theta42/proxy) shows up here too, since it's
the same kind of account.
<br>
Need an account something actually <i>runs as</i> on a Linux host instead
(a media manager, a torrent client, ...) — with a real <code>uidNumber</code>
and a group other accounts join for write access? That's a Unix account, not
a bind-only one — create it from <a href="/users">Users</a> with
<b>This is a service account</b> checked.
</p>
<div class="row g-3">
<div class="col-md-4">
<form action="service-account/" method="post" onsubmit="formAJAX(this)">
<div class="mb-2">
<label class="form-label">Name</label>
<input type="text" class="form-control shadow" name="cn" placeholder="ldapclient" validate=":1">
</div>
<div class="mb-2">
<label class="form-label">Description <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control shadow" name="description" placeholder="Bind account for gitea.example.com">
</div>
<button type="submit" class="btn btn-outline-dark btn-sm">
<i class="fa-solid fa-plus"></i> Create
</button>
</form>
</div>
<div class="col-md-8">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Name</th><th>Description</th><th></th></tr></thead>
<tbody jq-repeat="serviceAccountCard">
<tr>
<td><code>cn={{cn}},<%= userBase %></code></td>
<td>{{description}}</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-warning" title="Rotate password" onclick="rotateServiceAccountPassword('{{cn}}', this)">
<i class="fa-solid fa-key"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete" onclick="deleteServiceAccount('{{cn}}', this)">
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -610,11 +567,13 @@
'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 %>"',
'', '',
'# A read-only service account -- create one under Service Accounts', '# A read-only service account -- create one under Users > Service',
'# above, then fill in its password below.', '# Accounts, then fill in its password below.',
'export ldap_bind_dn="<%= exampleBindDn %>"', 'export ldap_bind_dn="<%= exampleBindDn %>"',
'export ldap_bind_password="CHANGE-ME"', 'export ldap_bind_password="CHANGE-ME"',
'', '',
+169 -5
View File
@@ -9,6 +9,7 @@
// data.photo = unescape(encodeURIComponent(data.jpegPhoto)); // data.photo = unescape(encodeURIComponent(data.jpegPhoto));
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow(); user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow(); user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.managerUids = (user.manager || []).map(app.user.dnToUid);
$.scope.user.update(user); $.scope.user.update(user);
$.scope.passwordReset.update(user); $.scope.passwordReset.update(user);
@@ -17,12 +18,77 @@
async function renderUserGroups(user){ async function renderUserGroups(user){
try{ try{
let res = await app.api.get('group/?detail=true&member='+user.uid); let res = await app.api.get('group/?detail=true&member='+user.uid);
$.scope.mygroups.empty();
$.scope.mygroups.push(...res.results); $.scope.mygroups.push(...res.results);
}catch(error){ }catch(error){
console.error('renderUserGroups error:', error) console.error('renderUserGroups error:', error)
} }
} }
async function removeFromGroup(cn, btn){
const $row = $(btn).closest('tr');
const confirmed = await app.util.actionConfirm(`Remove ${currentUser.uid} from "${cn}"?`, $row, 'warning');
if (!confirmed) return;
app.api.delete('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), function(error, data){
if(error){ app.util.actionMessage((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
$.scope.mygroups.remove('cn', cn);
});
}
var addGroupSelect;
async function addToGroups(btn){
const cns = addGroupSelect.get();
if(!cns.length) return;
const $card = $(btn).closest('.card-body');
for(const cn of cns){
await new Promise(function(resolve){
app.api.put('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), {}, function(error, data){
if(error) app.util.actionMessage((data && data.message) || `Failed to add to "${cn}"`, $card, 'danger');
resolve();
});
});
}
addGroupSelect.clear();
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/', '');
@@ -42,8 +108,15 @@
$.scope.editProfile.update(user); $.scope.editProfile.update(user);
// jq-repeat's update() is trailing-edge throttled (~50ms) as of 2.1.0 -- // jq-repeat's update() is trailing-edge throttled (~50ms) as of 2.1.0 --
// wait for the throttle tick to land before sliding the updated card // wait for the throttle tick to land before sliding the updated card
// into view, or it can briefly show stale/empty data. // into view, or it can briefly show stale/empty data. The manager
// picker is a JS widget, not a mustache-bound input, so it also has to
// wait for update() to (re-)render its empty mount div before attaching.
setTimeout(function(){ setTimeout(function(){
app.ui.userSelect('#edit-manager', {
name: 'manager',
values: user.managerUids || [],
placeholder: 'Type a username…',
});
$profileCard.slideUp(); $profileCard.slideUp();
$editCard.slideDown(); $editCard.slideDown();
}, 60); }, 60);
@@ -86,6 +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', {
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.
@@ -151,16 +233,20 @@
<div class="profile-body" jq-repeat="user"> <div class="profile-body" jq-repeat="user">
<div class="card-body profile-body-{{uid}}"> <div class="card-body profile-body-{{uid}}">
<h2><i>User Name:</i> <b>{{uid}}</b></h2> <h2><i>User Name:</i> <b>{{uid}}</b></h2>
<i>Name:</i> <b>{{givenName}} {{sn}}</b><br /> {{^isServiceAccount}}<i>Name:</i> <b>{{givenName}} {{sn}}</b><br />{{/isServiceAccount}}
<i>Email:</i> <b>{{mail}}</b> <i>Email:</i> <b>{{mail}}</b>
{{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}} {{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}}
<br /> <br />
<i>Phone:</i> <b>{{mobile}}</b> <i>Phone:</i> <b>{{mobile}}</b>
{{#phoneVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/phoneVerified}} {{#phoneVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/phoneVerified}}
<br /> <br />
<i>Location (Site):</i> <b>{{location}} </b><br />
<i>LDAP DN:</i> <b>{{dn}} </b><br /> <i>LDAP DN:</i> <b>{{dn}} </b><br />
<i>Home Directory:</i> <b>{{homeDirectory}} </b><br /> <i>Home Directory:</i> <b>{{homeDirectory}} </b><br />
<i>Login Shell:</i> <b>{{loginShell}} </b><br /> <i>Login Shell:</i> <b>{{loginShell}} </b><br />
<i>Manager(s):</i>
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
<br />
<i>Status:</i> <i>Status:</i>
{{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}} {{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}}
{{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}} {{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}}
@@ -236,7 +322,23 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Mobile Phone</label> <label class="form-label">Mobile Phone</label>
<input type="text" class="form-control" name="mobile" placeholder="9175551234" validate=":9" value="{{mobile}}" /> <input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" />
</div>
<div class="mb-3">
<label class="form-label">Location (Site)</label>
<input type="text" class="form-control" name="location" placeholder="Site One" value="{{location}}" />
</div>
<div class="mb-3">
<label class="form-label">Home Directory</label>
<input type="text" class="form-control" name="homeDirectory" placeholder="/home/jsmith" value="{{homeDirectory}}" />
</div>
<div class="mb-3">
<label class="form-label">Login Shell</label>
<input type="text" class="form-control" name="loginShell" placeholder="/bin/bash" value="{{loginShell}}" />
</div>
<div class="mb-3">
<label class="form-label">Manager(s)</label>
<div id="edit-manager"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">User Description (Optional)</label> <label class="form-label">User Description (Optional)</label>
@@ -254,12 +356,13 @@
<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>
<div class="card-header shadow actionMessage" style="display:none"> <div class="card-header shadow actionMessage" style="display:none">
</div> </div>
<div class="card-body" style="padding-bottom:0"> <div class="card-body">
<div class="table-responsive"> <div class="table-responsive">
<table class="table"> <table class="table">
<thead> <thead>
@@ -269,19 +372,78 @@
<th> <th>
Description Description
</th> </th>
<th class="group-required group-required-app_sso_admin"></th>
</thead> </thead>
<tbody jq-repeat="mygroups"> <tbody jq-repeat="mygroups">
<tr> <tr>
<td>{{cn}}</td> <td>{{cn}}</td>
<td>{{description}}</td> <td>{{description}}</td>
<td class="text-end group-required group-required-app_sso_admin">
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removeFromGroup('{{cn}}', this)">
<i class="fa-solid fa-xmark"></i>
</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="group-required group-required-app_sso_admin">
<label class="form-label small">Add to group</label>
<div class="d-flex gap-2 align-items-start">
<div id="add-group-select" class="flex-grow-1"></div>
<button type="button" class="btn btn-outline-dark" onclick="addToGroups(this)">Add</button>
</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> </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">
@@ -455,7 +617,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>
+51
View File
@@ -0,0 +1,51 @@
<%- include('header') %>
<div class="container-fluid" style="margin-top: 80px;">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card shadow-lg mb-4">
<div class="card-header">
<i class="fa-solid fa-network-wired"></i> Sites & Replication
</div>
<div class="card-body">
<p class="mb-4">
This page shows the status of Multi-Master LDAP replication peers.
<br/>Your Server ID: <strong><%= myId %></strong>
</p>
<table class="table table-striped table-bordered">
<thead class="table-dark">
<tr>
<th>Site LDAP URL</th>
<th>Replication Status</th>
</tr>
</thead>
<tbody>
<% if (sites.length === 0) { %>
<tr>
<td colspan="2" class="text-center text-muted">No replication peers configured in environment (LDAP_REPLICATION_HOSTS is empty).</td>
</tr>
<% } else { %>
<% sites.forEach(function(site) { %>
<tr>
<td class="align-middle"><strong><%= site.url %></strong></td>
<td class="align-middle">
<% if (site.status === 'Online') { %>
<span class="badge bg-success"><i class="fa-solid fa-circle-check"></i> Online</span>
<% } else { %>
<span class="badge bg-danger"><i class="fa-solid fa-circle-xmark"></i> <%= site.status %></span>
<% } %>
</td>
</tr>
<% }); %>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<%- include('footer') %>
+6
View File
@@ -56,6 +56,12 @@
Invites Invites
</a> </a>
</li> </li>
<li class="nav-item group-required group-required-app_sso_admin">
<a class="nav-link" href="/sites">
<i class="fa-solid fa-network-wired"></i>
Sites
</a>
</li>
<li class="nav-item group-required group-required-app_sso_admin"> <li class="nav-item group-required group-required-app_sso_admin">
<a class="nav-link" href="/dashboard"> <a class="nav-link" href="/dashboard">
<i class="fa-solid fa-gauge-high"></i> <i class="fa-solid fa-gauge-high"></i>
+13
View File
@@ -59,6 +59,14 @@ async function fetchUsernameSuggestions() {
$form.find('#personNameFields').toggle(!checked); $form.find('#personNameFields').toggle(!checked);
$form.find('#serviceAccountNameField').toggle(checked); $form.find('#serviceAccountNameField').toggle(checked);
// Service accounts aren't a person with a mailbox, and a blank
// password is fine (no userPassword attribute set -- the account
// simply can't bind). Disabling (not just hiding) keeps disabled
// fields out of both form serialization and validation.
$form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
$form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
$form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
if(checked){ if(checked){
// Filler values so the LDAP schema (inetOrgPerson requires sn) is // Filler values so the LDAP schema (inetOrgPerson requires sn) is
// satisfied; not shown anywhere, the account name is what matters. // satisfied; not shown anywhere, the account name is what matters.
@@ -133,6 +141,11 @@ async function fetchUsernameSuggestions() {
<input type="text" class="form-control shadow" name="mobile" placeholder="+14155551234" /> <input type="text" class="form-control shadow" name="mobile" placeholder="+14155551234" />
</div> </div>
<div class="mb-3">
<label class="form-label">Location (Site) <small class="text-muted">(optional)</small></label>
<input type="text" class="form-control shadow" name="location" placeholder="Site One" />
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Password</label> <label class="form-label">Password</label>
<input type="password" class="form-control shadow" name="userPassword" placeholder="Atleast 5 char. long" validate="password:5"/> <input type="password" class="form-control shadow" name="userPassword" placeholder="Atleast 5 char. long" validate="password:5"/>
+186 -100
View File
@@ -3,15 +3,17 @@
</script> </script>
<script type="text/javascript"> <script type="text/javascript">
function renderUsers(actionMessage, type){ function renderUsers(){
app.user.list(function(error, data){ app.user.list(function(error, data){
if(error){ if(error){
app.util.actionMessage(data.message, $target, 'danger'); app.util.actionMessage(data.message, $('#tab-people'), 'danger');
return; return;
} }
$.scope.userRow.push(...data.results); $.scope.userRow.empty();
$.scope.serviceAccountRow.empty();
const results = data.results || [];
$.scope.userRow.push(...results.filter(u => !u.isServiceAccount));
$.scope.serviceAccountRow.push(...results.filter(u => u.isServiceAccount));
}); });
} }
@@ -100,110 +102,194 @@
})(); })();
</script> </script>
<div class="row" style="display:none"> <h4><i class="fa-solid fa-users"></i> Users</h4>
<div class="col-md-4">
<div class="shadow-lg card mb-3 card-default group-required group-required-app_sso_admin"> <ul class="nav nav-tabs mb-3" role="tablist">
<div class="card-header shadow"> <li class="nav-item" role="presentation">
<i class="fas fa-user-plus"></i> <button class="nav-link active" id="tab-people-btn" data-bs-toggle="tab" data-bs-target="#tab-people" type="button" role="tab">
Invite User <i class="fa-solid fa-user"></i> People
<span class="float-end"> </button>
<i class="fa-solid fa-arrows-up-down"></i> </li>
</span> <li class="nav-item" role="presentation">
<button class="nav-link" id="tab-service-accounts-btn" data-bs-toggle="tab" data-bs-target="#tab-service-accounts" type="button" role="tab">
<i class="fa-solid fa-gears"></i> Service Accounts
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tab-people" role="tabpanel">
<div class="row" style="display:none">
<div class="col-md-4">
<div class="shadow-lg card mb-3 card-default group-required group-required-app_sso_admin">
<div class="card-header shadow">
<i class="fas fa-user-plus"></i>
Invite User
<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>
</span>
</div>
<div class="card-header shadow actionMessage" style="display: none;"></div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
</div>
<div class="mb-2">
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label>
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" />
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
</div>
<button onclick="sendInvite()" class="btn btn-sm btn-outline-dark shadow">
<i class="fa-solid fa-envelope"></i> Send Invite
</button>
<div id="invite-result" style="display:none" class="mt-2"></div>
</div>
</div> </div>
<div class="card-header shadow actionMessage" style="display: none;"></div> <div class="card shadow-lg">
<div class="card-body"> <div class="card-header">
<div class="mb-2"> <i class="fas fa-user-plus"></i>
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label> Add new user
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" /> <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="mb-2"> <div class="card-header actionMessage" style="display:none"></div>
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label> <div class="card-body">
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" /> <%- include('user_form', {adminMode: true}) %>
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
</div> </div>
<button onclick="sendInvite()" class="btn btn-sm btn-outline-dark shadow">
<i class="fa-solid fa-envelope"></i> Send Invite
</button>
<div id="invite-result" style="display:none" class="mt-2"></div>
</div> </div>
</div> </div>
<div class="card shadow-lg"> <div class="col-md-8">
<div class="card-header"> <div class="card shadow">
<i class="fas fa-user-plus"></i> <div class="card-header">
Add new user <i class="fa-solid fa-users"></i>
</div> User List
<div class="card-header actionMessage" style="display:none"></div> <a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<div class="card-body"> </div>
<%- include('user_form', {adminMode: true}) %> <div class="card-header actionMessage" style="display:none"></div>
</div> <div class="table-responsive">
</div> <table class="card-body table table-striped" style="margin-bottom:0">
</div> <thead>
<div class="col-md-8"> <th>ID</th>
<div class="card shadow"> <th>Name</th>
<div class="card-header"> <th>eMail</th>
<i class="fa-solid fa-users"></i> <th>Key</th>
User List <th>Active</th>
</div> <th>TOS</th>
<div class="card-header actionMessage" style="display:none"></div> <th></th>
<div class="table-responsive"> </thead>
<table class="card-body table table-striped" style="margin-bottom:0"> <tbody id="tableAJAX">
<thead> <tr jq-repeat="userRow">
<th>ID</th> <td>
<th>Name</th> {{ uidNumber }}
<th>eMail</th> </td>
<th>Key</th> <td>
<th>Active</th> <a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
<th>TOS</th> </td>
<th></th> <td>
</thead> {{mail}}
<tbody id="tableAJAX"> </td>
<tr jq-repeat="userRow"> <td>
<td> {{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
{{ uidNumber }} </td>
</td> <td>
<td> {{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a> {{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
{{#isServiceAccount}}<span class="badge bg-secondary" title="Service account — not a person"><i class="fa-solid fa-gears"></i> service</span>{{/isServiceAccount}} </td>
</td> <td>
<td> {{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}}
{{mail}} {{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}}
</td> </td>
<td> <td class="text-nowrap">
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}} {{#isActive}}
</td> <button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
<td> <i class="fa-solid fa-lock"></i>
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}} </button>
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}} {{/isActive}}
</td> {{#isInactive}}
<td> <button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
{{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}} <i class="fa-solid fa-lock-open"></i>
{{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}} </button>
</td> {{/isInactive}}
<td class="text-nowrap"> <button class="btn btn-sm btn-outline-secondary me-1" title="Impersonate" onclick="startImpersonate('{{uid}}')">
{{#isActive}} <i class="fa-solid fa-user-secret"></i>
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)"> </button>
<i class="fa-solid fa-lock"></i> <button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
</button> <i class="fa-solid fa-user-slash"></i>
{{/isActive}} </button>
{{#isInactive}} </td>
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)"> </tr>
<i class="fa-solid fa-lock-open"></i> </tbody>
</button> </table>
{{/isInactive}} </div>
<button class="btn btn-sm btn-outline-secondary me-1" title="Impersonate" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="tab-pane fade" id="tab-service-accounts" role="tabpanel">
<div class="row" style="display:none">
<div class="col-12">
<div class="card shadow">
<div class="card-header">
<i class="fa-solid fa-gears"></i>
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>
<a href="/docs/accounts" 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="table-responsive">
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>Username</th>
<th>Description</th>
<th>Manager(s)</th>
<th>Created</th>
<th>Active</th>
<th></th>
</thead>
<tbody>
<tr jq-repeat="serviceAccountRow">
<td>
<a href='/users/{{uid}}'>{{uid}}</a>
</td>
<td>
{{description}}
</td>
<td>
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
</td>
<td>
{{createTimestamp}}
</td>
<td>
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
</td>
<td class="text-nowrap">
{{#isActive}}
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i>
</button>
{{/isActive}}
{{#isInactive}}
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i>
</button>
{{/isInactive}}
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('impersonate_modal') %> <%- include('impersonate_modal') %>
<%- include('bottom') %> <%- include('bottom') %>
+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',