# Deployment Guide — SSO Manager Two supported deployment methods: 1. **Docker** — a single all-in-one image bundling the app + OpenLDAP + Redis (`docker compose up`). 2. **Bare metal** — `install.sh` on Debian/Ubuntu (installs Node.js, OpenLDAP, Redis, the app, and a systemd unit). ## How configuration works The app loads configuration via [`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf), which deep-merges, in order: 1. `conf/base.js` (committed, generic defaults) 2. `conf/.js` (optional) 3. `conf/secrets.js` (gitignored — secrets + per-deployment values) 4. **`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 honor > `app_*` env vars on 1.0.0. Before building the image, refresh the app's > dependency lock from the `nodejs/` directory: > ```bash > 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: ```bash 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,`, > `ldap.userBase` = `ou=people,`, `ldap.groupBase` = `ou=groups,`, > and `stack.ldapDomain` = the dotted form (`718it.biz`). `oauth.issuer` is the > public SSO URL (`https://`). Drifting these apart (e.g. leaving > `ldap.bindDN` at `dc=example,dc=com` while `stack.ldapBaseDn` is your real > domain) makes the SSO bind against a non-existent root DN and every login > fails with `Invalid Credentials`. > > Running the unified `theta-env` stack? You don't hand-edit these DNs at all > — its `setup.sh` generates `./config/sso-secrets.js` (+ `./config/proxy-secrets.js`) > from a single `setup.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): 1. If `/config/sso-secrets.js` is mounted, symlinks it to `/app/conf/secrets.js` and reads the server-side LDAP vars from it (secrets.js mode). Otherwise it derives them from `LDAP_*` env vars with safe defaults (env mode). 2. Generates a self-signed TLS cert (unless one is already present at `LDAP_CERT_DIR`), generates a `slapd.conf` for the bundled OpenLDAP (`mdb` database, `pw-sha2`/`ppolicy`/`memberof`/`refint` modules + overlays, TLS, indexes, access controls), and starts `slapd -f /etc/openldap/slapd.conf` listening on `ldap:///` (389) and `ldaps:///` (636). 3. Seeds the directory (base DN, `ou=people`/`ou=groups`/`ou=policies`, a default `pwdPolicy`, and the required SSO groups `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`, `app_sso_service_account`) — idempotently, so container restarts are safe. 4. Starts a bundled Redis (the app uses `model-redis` for models/sessions and stores OAuth clients there), AOF+RDB persisted to `/data`, unless `app_redis__host` is set (then it's expected to be external). 5. 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). 6. `exec`s `node 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://: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: ```bash curl -H "Authorization: Bearer sso__" https://sso.example.com/api/user ``` Format: `sso__` — 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). ```bash 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.` | 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) | 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. - **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 `TLS_REQCERT never` for quick-and-dirty LAN use. Fetch it with: ```bash docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt ``` - **Use your own cert** (CA-signed / internal CA): replace the `ldap-certs` named volume with a bind mount containing your own `ldap.crt` + `ldap.key`: ```yaml volumes: - ./certs:/etc/openldap/certs # must contain ldap.crt + ldap.key ``` The entrypoint leaves existing certs untouched (idempotent). > 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 `389` mapping in `docker-compose.yml` only 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`](https://github.com/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: 1. **Put them on one Docker network** so the proxy can reach the SSO Manager internally at `http://sso-manager:3001` for token/userinfo (server-to-server), without exposing the SSO Manager's HTTP port to the internet: ```yaml # in the proxy's compose, or a shared external network: networks: - sso-net ``` 2. **Set the SSO's `OAUTH_ISSUER`** to the *browser-facing* HTTPS URL the proxy serves the SSO at (e.g. `https://sso.yourdomain.com`). The proxy's `oidc.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 internal `http://sso-manager:3001` URL; only the issuer/redirect URLs must be public. 3. **Register the proxy as an OAuth/OIDC client** in the SSO Manager UI, with a `redirectUri` matching the proxy's callback (e.g. `https://proxy.yourdomain.com/api/auth/oidc/callback`), and put the client secret in the proxy's `secrets.js`. 4. **LDAP for the proxy**: point the proxy's `ldap.url` at `ldaps://sso-manager:636` (TLS, same Docker network) rather than a LAN IP, and create a dedicated LDAP service account under `ou=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//` 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): ```bash ./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//`, 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: ```bash # 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`: ```bash # 1. Secrets cp -a config-backup- ./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-.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-.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 loads > `appendonly.aof` on startup and **ignores** `dump.rdb` if 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 DBSIZE` and > `docker compose exec sso-manager ldapsearch -x -b "dc=yourdomain,dc=com"`. **Upgrades** ```bash ./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 ```bash wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash ``` or, if you already have the repo checked out: ```bash 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 ```bash sudo systemctl status sso-manager journalctl -fu sso-manager curl http://localhost:3001/health # -> {"status":"ok"} ``` ### What `install.sh` does 1. Installs Node.js 22.x (NodeSource) and Redis. 2. Clones/updates the repo at `/opt/theta42/sso-manager`. 3. **First run only:** installs OpenLDAP (`slapd`) with `pw-sha2`, `ppolicy`, `memberof`, `refint` modules + overlays; the custom `theta42Person` schema (`dateOfBirth`); indexes; `ou=people`/`ou=groups`/`ou=policies`; a default `pwdPolicy`; and the SSO groups — then seeds `/etc/sso-manager/secrets.js`. 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 with `SKIP_LDAP=true` and write > `/etc/sso-manager/secrets.js` yourself (see `secrets.js.example`) before > starting the service. To (re)configure overlays on an already-installed > slapd, use `ops/ldap-setup.sh` directly (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 `theta42Person` auxiliary objectClass with `dateOfBirth` (OID `1.3.6.1.4.1.99999.x`) — see `ops/ldap-setup.sh` for the LDIF. - **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN, a default `pwdPolicy` at `cn=ppolicy,ou=policies,`. - **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 a `posixAccount` as a non-person service account; see docs/ldap.md). `ops/ldap-setup.sh -p ` 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.js` into your gitignored `conf/secrets.js`, **or** set them as `app_*` env vars. Secret values (LDAP bind password, SMTP password, JWT secret) already belong in `secrets.js`. - After the change, verify the merged config: `node -e "console.log(require('@simpleworkjs/conf'))"` from the `nodejs/` 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: ```bash 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: ```bash 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+: ```bash cd nodejs && npm install @simpleworkjs/conf@^1.1.0 ``` ### LDAP connection refused ```bash 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 1. **Never commit `secrets.js`** — it's in `.gitignore`. 2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain + StartTLS); port 389 is not mapped to the host by default so LAN clients can't bind in cleartext. Direct-LDAP apps (legacy services, `theta42/proxy`) should use `ldaps://…:636` or StartTLS. 3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't set `JWT_SECRET`, issued tokens invalidate on container recreation. 4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front proxy and keep `3001` on the Docker network / localhost only. 5. **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. 6. The all-in-one image runs slapd as the `ldap` user but the app process as root (matches the bare-metal systemd unit). Harden the app to a non-root user for production if needed.