23 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 (for legacy apps / direct binds):
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.
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 apps (legacy services,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.