Closes the end-user half of the directory and adds nested LDAP groups.
The directory could describe the lab but could not tell anyone what they had
or how to reach it, and several of the paths meant to do so were silently
returning nothing:
- GET /api/discovery/me resolved groups from req.user.groups, which does not
exist (req.user carries memberOf), so it returned only isPublic resources
for every human caller -- "My Services" was blank for everyone. The same
read made isDirectoryAdmin() false for real admins.
- The portal's "Discover More Services" called the admin-gated endpoint and
swallowed the 403, so it never rendered for non-admins at all.
- Services reported no address, because /me had reimplemented getMyAccess
without its parent-walking resolution.
Adds the catalog at /, self-service access requests, and admin access
visibility (per-resource counts, and the reverse "what can this user reach").
Nested groups come in two halves. groupOfNames.member already accepts a group
DN, so nesting needs no schema -- what it needs is resolution, which no
released OpenLDAP performs. The all-in-one image therefore builds slapd from a
pinned master commit for the nestgroup overlay, and the app computes the
closure itself when pointed at a server without it. Both paths are covered.
member-values is deliberately left out of nestgroup-flags: it expands `member`
when reading a group, which destroys the distinction between "listed here" and
"reachable through a nested group" and is not recoverable afterwards.
Full suite green in both resolution modes: 215 passed, 2 skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24 KiB
Deployment Guide — SSO Manager
Two supported deployment methods:
- Docker — a single all-in-one image bundling the app + OpenLDAP + Redis (
docker compose up). - Bare metal —
install.shon Debian/Ubuntu (installs Node.js, OpenLDAP, Redis, the app, and a systemd unit).
How configuration works
The app loads configuration via @simpleworkjs/conf, which deep-merges, in order:
conf/base.js(committed, generic defaults)conf/<NODE_ENV>.js(optional)conf/secrets.js(gitignored — secrets + per-deployment values)app_*environment variables — the highest-precedence layer
Any env var whose name starts with app_ overrides the merged config. The rest
of the name is split on double-underscore (__) into a nested path. Values
are JSON.parse-coerced when possible (numbers, booleans, null, JSON) and kept
as raw strings otherwise. Examples:
| Env var | Sets | Type |
|---|---|---|
app_ldap__url=ldap://host:389 |
conf.ldap.url |
string |
app_ldap__bindPassword=secret |
conf.ldap.bindPassword |
string |
app_ldap__uidGidMin=1500 |
conf.ldap.uidGidMin |
number (new-user id floor) |
app_oauth__jwtSecret=... |
conf.oauth.jwtSecret |
string |
app_smtp__secure=false |
conf.smtp.secure |
boolean |
app_oauth__token_lifetime__access_token=3600 |
conf.oauth.token_lifetime.access_token |
number |
app_name=My SSO |
conf.name |
string |
Requires
@simpleworkjs/conf>= 1.1.0. The Docker image will not honorapp_*env vars on 1.0.0. Before building the image, refresh the app's dependency lock from thenodejs/directory:cd nodejs && npm install @simpleworkjs/conf@^1.1.0
Method 1: Docker (all-in-one)
The image (Dockerfile.openldap) bundles OpenLDAP, Redis, and the app in one container.
The app connects to the bundled slapd over localhost:389 automatically; you only
need to set a few secrets.
Setup
The bundled docker-compose.yml reads config from a bind-mounted
./config/sso-secrets.js (not from a .env file). Copy the example, fill in
your secrets, then build + start:
mkdir -p config && chmod 700 config
cp secrets.js.example config/sso-secrets.js
$EDITOR config/sso-secrets.js # set ldap.bindPassword, oauth.jwtSecret, ...
docker compose up -d --build
docker-entrypoint.sh symlinks /config/sso-secrets.js → /app/conf/secrets.js
so @simpleworkjs/conf reads it, and pulls the server-side LDAP vars (base DN,
admin password, org, domain, cert CN, JWT secret) out of the same file. No
app_* env is passed — app_* env would override secrets.js (env beats the
file in @simpleworkjs/conf), so the file is kept authoritative.
Your domain is entered once, as the LDAP base DN. Set
stack.ldapBaseDn(e.g.dc=718it,dc=biz) and keep the LDAP DNs consistent with it — they all derive from that one value:ldap.bindDN=cn=admin,<dn>,ldap.userBase=ou=people,<dn>,ldap.groupBase=ou=groups,<dn>, andstack.ldapDomain= the dotted form (718it.biz).oauth.issueris the public SSO URL (https://<ssoHost>). Drifting these apart (e.g. leavingldap.bindDNatdc=example,dc=comwhilestack.ldapBaseDnis your real domain) makes the SSO bind against a non-existent root DN and every login fails withInvalid Credentials.Running the unified
theta-envstack? You don't hand-edit these DNs at all — itssetup.shgenerates./config/sso-secrets.js(+./config/proxy-secrets.js) from a singlesetup.env(where the domain is asked once, as the base DN) with random secrets, and snapshots state before rebuilds — so the DNs can't drift. See the theta-env README.
Quick test (defaults): with no ./config/sso-secrets.js the entrypoint
falls back to env-mode with safe defaults (dc=example,dc=com, admin password
admin, an auto-generated JWT secret) — fine for kicking the tires, not for
production.
Advanced — env vars instead of the file: the entrypoint also supports
config via LDAP_* / app_* env vars (env-mode, used when
/config/sso-secrets.js is absent). Since the bundled compose no longer passes
those env vars, you'd add them to its environment: block yourself, e.g.
LDAP_ADMIN_PASS, JWT_SECRET, app_oauth__issuer. This is mainly for
bare-metal / advanced standalone use; most deployments should use the file.
What the entrypoint does
docker-entrypoint.sh (run as the container entrypoint):
- If
/config/sso-secrets.jsis mounted, symlinks it to/app/conf/secrets.jsand reads the server-side LDAP vars from it (secrets.js mode). Otherwise it derives them fromLDAP_*env vars with safe defaults (env mode). - Generates a self-signed TLS cert (unless one is already present at
LDAP_CERT_DIR), generates aslapd.conffor the bundled OpenLDAP (mdbdatabase,pw-sha2/ppolicy/memberof/refintmodules + overlays, TLS, indexes, access controls), and startsslapd -f /etc/openldap/slapd.conflistening onldap:///(389) andldaps:///(636). - Seeds the directory (base DN,
ou=people/ou=groups/ou=policies, a defaultpwdPolicy, and the required SSO groupsapp_sso_admin,app_sso_invite,app_sso_oauth_admin,app_sso_service_account) — idempotently, so container restarts are safe. - Starts a bundled Redis (the app uses
model-redisfor models/sessions and stores OAuth clients there), AOF+RDB persisted to/data, unlessapp_redis__hostis set (then it's expected to be external). - In env mode, exports
app_*env vars so the app binds to the local slapd. In secrets.js mode it exports none (the app reads the file directly). execsnode bin/www.
Access
- SSO Manager UI:
http://localhost:3001(HTTP inside the container — put a TLS-terminating proxy in front for browser access) - Health check:
http://localhost:3001/health→{"status":"ok"} - OIDC discovery:
http://localhost:3001/.well-known/openid-configuration - LDAP (internal, app↔slapd):
ldap://localhost:389(not mapped to the host) - LDAPS (direct binds: Linux hosts, LDAP-native apps):
ldaps://<host>:636(TLS)
API tokens (personal access tokens)
Any logged-in user can mint a long-lived bearer token to call the management
API from scripts/CI/other services, without a browser session. Tokens are
self-service and authenticate as their creator — a token carries the
creator's LDAP group permissions, so the same permission.byGroup checks apply
(group membership is re-resolved from LDAP live on each request).
Create one in the UI under API Tokens (the token string is shown once), then use it as a bearer token:
curl -H "Authorization: Bearer sso_<id>_<secret>" https://sso.example.com/api/user
Format: sso_<id>_<secret> — the id is the lookup key, the secret is
bcrypt-hashed and never stored in plaintext. Rotate or revoke a token from the
same UI page; revocation takes effect immediately. Optional expiry (in days) at
creation. API tokens persist in the bundled Redis, so they survive rebuilds
(Redis is persisted via AOF — see Backups and restore).
The token has the same access as a browser session for that user — an
app_sso_admin's token can manage users/groups; a non-admin's token is limited
to what they could do in the UI.
Logs
The all-in-one image runs the Node app and slapd (OpenLDAP) in one container,
both writing to the container's stdout/stderr, so docker compose logs is the
primary view (slapd runs with -d 0, so LDAP output is there too).
docker compose logs -f sso-manager # app + slapd (stdout/stderr)
docker compose logs --tail=200 --since=10m sso-manager # recent context
docker compose exec sso-manager ldapsearch -x -H ldap://localhost:389 \
-D "cn=admin,$LDAP_BASE_DN" -W -b "$LDAP_BASE_DN" # LDAP health check
Available environment variables
| Variable | Default | Description |
|---|---|---|
LDAP_BASE_DN |
dc=example,dc=com |
slapd suffix + app user/group base |
LDAP_DOMAIN |
derived from LDAP_BASE_DN |
DNS domain; default for LDAP_CERT_CN and OAuth issuer |
LDAP_ADMIN_PASS |
admin |
slapd root password + app bind password |
ORG_NAME |
SSO Manager |
org name in UI/email/group descriptions |
JWT_SECRET |
auto-generated | OAuth JWT signing secret (persist it!) |
OAUTH_ISSUER |
https://sso.<LDAP_DOMAIN> |
OIDC issuer in the discovery doc (browser-facing URL) |
LDAP_CERT_CN |
LDAP_DOMAIN |
CN/SAN on the LDAPS cert (hostname clients verify against) |
LDAP_CERT_DIR |
/etc/openldap/certs |
where the entrypoint looks for ldap.crt+ldap.key (mount your own here) |
SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASS/SMTP_FROM |
localhost / 587 / empty | outbound email |
PORT |
3001 |
host port mapped to the UI |
LDAPS_PORT |
636 |
host port mapped to LDAPS |
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
table at the top).
LDAP TLS (LDAPS / StartTLS)
The bundled slapd generates a self-signed cert on first start (CN = LDAP_CERT_CN,
valid 10 years, SAN includes the CN + localhost + 127.0.0.1) and listens on
ldaps:/// (636) plus offers StartTLS on ldap:/// (389). The cert is stored on the
ldap-certs volume so it persists across container recreation — clients don't need
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.crtout of the container and add it to the client's trusted CA store, or setTLS_REQCERT neverfor quick-and-dirty LAN use. Fetch it with:docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt - Use your own cert (CA-signed / internal CA): replace the
ldap-certsnamed volume with a bind mount containing your ownldap.crt+ldap.key:The entrypoint leaves existing certs untouched (idempotent).volumes: - ./certs:/etc/openldap/certs # must contain ldap.crt + ldap.key
Port 389 (plain LDAP) is not mapped to the host by default, to avoid cleartext password binds over the LAN. Direct-LDAP clients should use LDAPS (636) or StartTLS. Uncomment the
389mapping indocker-compose.ymlonly if you need plain LAN binds and accept the risk.
Fronting with a reverse proxy (theta42/proxy)
The SSO Manager runs HTTP inside the container; terminate TLS at a front proxy.
The theta42/proxy is an OIDC-protected reverse
proxy and a natural fit — it's both an OIDC client of the SSO Manager and a
direct LDAP client for user lookups. To run both together:
- Put them on one Docker network so the proxy can reach the SSO Manager
internally at
http://sso-manager:3001for token/userinfo (server-to-server), without exposing the SSO Manager's HTTP port to the internet:# in the proxy's compose, or a shared external network: networks: - sso-net - Set the SSO's
OAUTH_ISSUERto the browser-facing HTTPS URL the proxy serves the SSO at (e.g.https://sso.yourdomain.com). The proxy'soidc.issuer/endpoints must match — it can get them from the SSO's/.well-known/openid-configuration. Server-to-server calls from the proxy go to the internalhttp://sso-manager:3001URL; only the issuer/redirect URLs must be public. - Register the proxy as an OAuth/OIDC client in the SSO Manager UI, with a
redirectUrimatching the proxy's callback (e.g.https://proxy.yourdomain.com/api/auth/oidc/callback), and put the client secret in the proxy'ssecrets.js. - LDAP for the proxy: point the proxy's
ldap.urlatldaps://sso-manager:636(TLS, same Docker network) rather than a LAN IP, and create a dedicated LDAP service account underou=people(e.g.cn=ldapclient,ou=people,…) via the SSO Manager UI — don't reuse the admin DN.
Backups and restore
What lives where
| State | Location | Persisted? |
|---|---|---|
| LDAP directory (users, groups, policies) | ldap-data volume (/var/lib/ldap) |
yes (volume) |
| LDAP TLS cert | ldap-certs volume (/etc/openldap/certs) |
yes (volume) |
| Redis (OAuth clients, tokens, sessions) | sso-data volume (/data) |
yes (AOF + RDB) |
| Secrets (LDAP admin pass, JWT secret, SMTP) | ./config/sso-secrets.js (bind mount) |
your responsibility — back up off-host |
Automatic snapshots — when run as part of the unified theta-env stack,
setup.sh snapshots LDAP + Redis + ./config/ to ./backups/<timestamp>/
before every rebuild and keeps the last BACKUP_KEEP (default 5). Standalone
deployments should run ops/backup.sh the same way (on a cron/systemd timer,
or by hand before an upgrade):
./ops/backup.sh # keeps the last 5 by default
./ops/backup.sh 10 # or override retention
BACKUP_KEEP=10 ./ops/backup.sh
It snapshots LDAP (slapcat, auto-detecting your base DN from
./config/sso-secrets.js), Redis (BGSAVE, falling back to a synchronous
SAVE if that doesn't complete quickly), and ./config/ to
./backups/<timestamp>/, pruning older backups beyond the retention count —
the same approach theta-env's setup.sh uses, just scoped to this one
container. Equivalent manual steps, if you'd rather not use the script:
# LDAP — full directory export (works while slapd is running)
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
-b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif
# Redis — hot snapshot: trigger a save, then copy the RDB out
docker compose exec sso-manager redis-cli BGSAVE
docker compose cp sso-manager:/data/dump.rdb sso-redis-$(date +%F).rdb
# Secrets — copy the config dir (holds LDAP_ADMIN_PASS, JWT secret, etc.)
cp -a ./config config-backup-$(date +%F) && chmod 700 config-backup-$(date +%F)
Store the backup off the host — it contains secrets and the whole user directory.
Restore — full (disaster recovery)
The SSO image uses a static slapd.conf (slapd starts with -f, not -F
cn=config), so LDAP restore uses slapadd -f /etc/openldap/slapd.conf:
# 1. Secrets
cp -a config-backup-<date> ./config && chmod 700 ./config
./setup.sh # fresh empty volumes (or: docker compose up -d)
docker compose stop sso-manager
# 2. LDAP — wipe the mdb files, then load the LDIF into the stopped directory
docker compose run --rm --no-deps --entrypoint sh sso-manager -c \
'rm -f /var/lib/ldap/* && slapadd -f /etc/openldap/slapd.conf -l /dev/stdin' \
< ldap-backup-<date>.ldif
docker compose start sso-manager
# 3. Redis — see the AOF note below
docker compose stop sso-manager
docker compose run --rm --no-deps --entrypoint sh sso-manager -c \
'rm -f /data/appendonly.aof /data/appendonly.aof.*' # REQUIRED — see note
docker compose cp sso-redis-<date>.rdb sso-manager:/data/dump.rdb
docker compose start sso-manager
Restore — Redis only = step 3 above. Restore — LDAP only = step 2 above.
AOF vs RDB (important): with
--appendonly yes, Redis loadsappendonly.aofon startup and ignoresdump.rdbif the AOF exists. To restore from an RDB snapshot you must delete the AOF first (step 3 does this); Redis then loads the RDB and writes a fresh AOF. Verify after restoring:docker compose exec sso-manager redis-cli DBSIZEanddocker compose exec sso-manager ldapsearch -x -b "dc=yourdomain,dc=com".
Upgrades
./setup.sh # backs up, then rebuilds — volumes keep LDAP + Redis state
# (standalone) docker compose pull && docker compose up -d
LDAP data and Redis state survive the rebuild because they live on named
volumes, not in the image. Verify health (docker compose ps, log in, check an
OAuth client). Note: re-running bootstrap resets the bootstrap-admin and
service-account passwords to the values in ./config/sso-secrets.js; non-theta
OAuth clients live in SSO Redis and are preserved by the volume.
Note — the bundled slapd is built from source. The all-in-one image compiles OpenLDAP from a pinned upstream commit to get the
nestgroupoverlay (nested groups; seedocs/directory.md), because no 2.6.x release ships it. One consequence: master uses LMDB 1.0.0, whose on-disk format is mutually unreadable with the 0.9.x in OpenLDAP 2.6.x (MDB_INVALID: File is not an LMDB file). Moving a directory between a 2.6.x image and this one is aslapcat→slapaddreload, not a restart — the same shape as "Restore — LDAP only" above. Verify after a rebuild:docker compose logs sso-manager | grep nestgroupshould report the overlay as available.
Method 2: Bare metal (Debian/Ubuntu)
install.sh is an idempotent installer: it installs Node.js 22.x and Redis,
force-syncs the repo to /opt/theta42/sso-manager, and symlinks the systemd
config from the repo. Re-run it to update — it prints the version you're
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
- Debian 11+ / Ubuntu 20.04+
- Root (
sudo) - Internet access
Install
wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
or, if you already have the repo checked out:
sudo ./install.sh
| Env var | Description |
|---|---|
LDAP_BASE_DN |
Base DN (default dc=example,dc=com) — first run only |
LDAP_ADMIN_PASS |
LDAP admin password (default auto-generated) — first run only |
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
sudo systemctl status sso-manager
journalctl -fu sso-manager
curl http://localhost:3001/health # -> {"status":"ok"}
What install.sh does
- Installs Node.js 22.x (NodeSource) and Redis.
- Clones/updates the repo at
/opt/theta42/sso-manager. - First run only: installs OpenLDAP (
slapd) withpw-sha2,ppolicy,memberof,refintmodules + overlays; the customtheta42Personschema (dateOfBirth); indexes;ou=people/ou=groups/ou=policies; a defaultpwdPolicy; and the SSO groups — then seeds/etc/sso-manager/secrets.js. - Symlinks
ops/systemd/sso-manager.serviceinto/etc/systemd/systemand runsnpm ci --omit=dev. - Enables and (re)starts the service.
For an existing LDAP server, run with
SKIP_LDAP=trueand write/etc/sso-manager/secrets.jsyourself (seesecrets.js.example) before starting the service. To (re)configure overlays on an already-installed slapd, useops/ldap-setup.shdirectly (idempotent, auto-detects the user database).
LDAP requirements (for any external LDAP server)
The app needs these on the LDAP server:
- Modules:
pw-sha2(the app stores user passwords as{SSHA512}),ppolicy,memberof,refint. - Custom schema: the
theta42Personauxiliary objectClass withdateOfBirth(OID1.3.6.1.4.1.99999.x) — seeops/ldap-setup.shfor the LDIF. - Directory tree:
ou=people,ou=groups,ou=policiesunder the base DN, a defaultpwdPolicyatcn=ppolicy,ou=policies,<base>. - Required groups:
app_sso_admin(full admin),app_sso_invite(invitation management),app_sso_oauth_admin(OAuth client management),app_sso_service_account(not a permission — marks aposixAccountas a non-person service account; see docs/ldap.md).
ops/ldap-setup.sh -p <admin-password> configures all of the above idempotently
against a running slapd (auto-detects the database holding your base DN, and
verifies pwdAccountLockedTime is live — the attribute the app's
active/inactive toggle depends on).
Migrating an existing instance to the generic defaults
The committed nodejs/conf/base.js now ships generic defaults
(dc=example,dc=com, localhost, SSO Manager). Previously it carried
Theta42-specific values (LDAP bind DN/bases, SMTP host/user/sender, OAuth issuer).
If you run an existing instance off this repo:
- Move those per-deployment, non-secret values (bind DN, user/group bases, SMTP
host/user/sender, OAuth issuer, org name) from
base.jsinto your gitignoredconf/secrets.js, or set them asapp_*env vars. Secret values (LDAP bind password, SMTP password, JWT secret) already belong insecrets.js. - After the change, verify the merged config:
node -e "console.log(require('@simpleworkjs/conf'))"from thenodejs/directory.
Troubleshooting
503 OpenLDAP ppolicy overlay is not configured
The ppolicy overlay isn't attached to the database holding your users, so the
active/inactive toggle can't set pwdAccountLockedTime. Run:
sudo ./ops/ldap-setup.sh -p 'admin-password' -b dc=yourdomain,dc=com
App starts but LDAP operations 401 / "Invalid Credentials"
Check the merged LDAP config the app actually sees:
cd nodejs && node -e "console.log(require('@simpleworkjs/conf').ldap)"
Confirm url/bindDN/bindPassword/userBase match your directory. Remember
app_* env vars override secrets.js which overrides base.js.
app_* env vars seem to do nothing
You're on @simpleworkjs/conf 1.0.0. Bump to 1.1.0+:
cd nodejs && npm install @simpleworkjs/conf@^1.1.0
LDAP connection refused
docker compose exec sso-manager sh -c 'ldapsearch -x -H ldap://localhost:389 -b "" -s base'
systemctl status slapd # bare metal
netstat -tlnp | grep 389
Security notes
- Never commit
secrets.js— it's in.gitignore. - Use LDAPS / StartTLS for any LDAP connection that crosses the network. The
bundled slapd listens on
ldaps:///(636, TLS) andldap:///(389, plain + StartTLS); port 389 is not mapped to the host by default so LAN clients can't bind in cleartext. Direct-LDAP consumers (Linux hosts, LDAP-native apps,theta42/proxy) should useldaps://…:636or StartTLS. - Persist
JWT_SECRET— if the Docker image auto-generates one and you don't setJWT_SECRET, issued tokens invalidate on container recreation. - Don't expose the UI's HTTP port to the internet — terminate TLS at a front
proxy and keep
3001on the Docker network / localhost only. - Don't port-forward LDAPS (636) to the internet either. It's mapped to the
host by default for LAN/VPN clients that bind LDAP directly (other hosts
running
ldap-client, apps with their own LDAP auth settings) — not for exposure through your router/firewall. LDAP simple-bind is a brute-force target with no rate limiting in front of it the way the HTTP login endpoints have. If a remote host needs to bind LDAP, put it behind a VPN (Tailscale, WireGuard, …) instead of forwarding 636 publicly. - The all-in-one image runs slapd as the
ldapuser but the app process as root (matches the bare-metal systemd unit). Harden the app to a non-root user for production if needed.