diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md deleted file mode 100644 index 31f00f1..0000000 --- a/DEPLOYMENT.md +++ /dev/null @@ -1,512 +0,0 @@ -# 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 (direct binds: Linux hosts, LDAP-native apps): `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) | -| `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.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. - -> **Note — the bundled slapd is built from source.** The all-in-one image -> compiles OpenLDAP from a pinned upstream commit to get the `nestgroup` -> overlay (nested groups; see `docs/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 a `slapcat` → `slapadd` reload, not a restart — the same -> shape as "Restore — LDAP only" above. Verify after a rebuild: -> `docker compose logs sso-manager | grep nestgroup` should 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 - -```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 consumers (Linux hosts, LDAP-native apps, - `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. \ No newline at end of file diff --git a/docs/_config.yml b/docs/_config.yml deleted file mode 100644 index f07b372..0000000 --- a/docs/_config.yml +++ /dev/null @@ -1,55 +0,0 @@ -title: SSO Manager -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. -url: "https://theta42.github.io" -baseurl: "/sso-manager-node" -logo: /assets/img/theta42.svg -lang: en_US - -plugins: - - jekyll-seo-tag - - jekyll-sitemap - -github: - repository_url: https://github.com/theta42/sso-manager-node - 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 - 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: Directory - page: /directory.html - icon: fa-server - - title: Plugins - page: /plugins.html - icon: fa-plug - # API.md lives at the repo root, not under docs/, so Jekyll never renders an - # api.html for it — link the source directly, same as the Changelog. - - title: API - url: https://github.com/theta42/sso-manager-node/blob/master/API.md - icon: fa-code - - 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 diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html deleted file mode 100644 index 39af120..0000000 --- a/docs/_layouts/default.html +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - {% seo title=false %} - {% if page.title %}{{ page.title }} · {% endif %}{{ site.title }} - - - - - - - - - -
-
-
-
-
-
- {{ content }} -
-
-
-
-
-
- - - - - - diff --git a/docs/agents.md b/docs/agents.md deleted file mode 100644 index 44e6768..0000000 --- a/docs/agents.md +++ /dev/null @@ -1,88 +0,0 @@ ---- -layout: default -title: Discovery Agents -nav_order: 5 ---- - -# Discovery Agents - -The SSO Manager supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs. - -## Writing a Custom Agent - -Agents are simple JavaScript files placed in `nodejs/agents/discovery/`. - -A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`. - -### Agent Skeleton - -```javascript -// nodejs/agents/discovery/my_custom_agent.js -module.exports = { - discover: async (config) => { - const { url, apiKey } = config; // Provided by your configuration - - const resources = []; - const edges = []; - - // 1. Fetch your data from an API - // const data = await fetch(...); - - // 2. Map data to Resources - resources.push({ - kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device' - name: 'My Switch', - slug: 'my-switch-01', - metadata: { - make: 'Vendor', - model: 'Model X', - interfaces: [ - { mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' } - ] - } - }); - - // 3. Map relations to Edges (optional) - edges.push({ - parentSlug: 'my-switch-01', - childSlug: 'some-connected-client-slug', - relation: 'connected_to' // 'hosts', 'exposes', 'connected_to' - }); - - return { resources, edges }; - } -}; -``` - -## Configuration - -Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`: - -```javascript -module.exports = { - // ... existing config ... - discovery: { - agents: { - my_custom_agent: { - enabled: true, - cron: '*/30 * * * *', // Run every 30 minutes - url: 'https://api.example.com', - apiKey: 'secret-key' - }, - nmap: { - enabled: true, - cron: '0 * * * *', - targetRange: '192.168.1.0/24' - } - } - } -}; -``` - -## The Reconciliation Engine - -When your agent returns its graph, the Reconciliation Engine takes over: -1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`. -2. **Merging:** If it finds a match, it gracefully merges the metadata (so your agent can add CPU info to a host that NMAP previously found). -3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp. -4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them. diff --git a/docs/assets/css/style.css b/docs/assets/css/style.css deleted file mode 100644 index e24a5de..0000000 --- a/docs/assets/css/style.css +++ /dev/null @@ -1,116 +0,0 @@ -/* 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; -} diff --git a/docs/assets/img/theta42.svg b/docs/assets/img/theta42.svg deleted file mode 100644 index e598305..0000000 --- a/docs/assets/img/theta42.svg +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 42 - - diff --git a/docs/concepts-accounts.md b/docs/concepts-accounts.md deleted file mode 100644 index e782ab5..0000000 --- a/docs/concepts-accounts.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -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/Directory/Overview -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. - -### Groups inside groups - -A group can contain another group, not just people — the *Nested* tab on any -group card. Everyone in the inner group counts as a member of the outer one, -however many levels deep it goes. - -This is mostly a way to stop repeating yourself. Make one `developers` group, -nest it into the handful of things developers should reach, and adding a new -developer to that one group grants all of them at once — instead of adding them -to each individually and slowly drifting out of sync. The app already does this -for itself: super admins are nested into every resource's admin group, and each -admin group into its access group, so "can administer it" always implies "can -use it". - -Two things it won't let you do: put a group inside itself (directly or round a -longer loop), and empty a group completely — every group must keep at least one -member. - -A note if you also manage the directory by hand: a group's member list shows -what is *directly* listed on it. Someone who gets in through a nested group is -a real member but won't appear there — the **Nested** tab shows what is nested, -and the API's `effective` view lists everyone who actually gets in. - -## 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 ``'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) diff --git a/docs/concepts-api-tokens.md b/docs/concepts-api-tokens.md deleted file mode 100644 index 36eeb9d..0000000 --- a/docs/concepts-api-tokens.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -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](https://github.com/theta42/sso-manager-node/blob/master/API.md). - -## 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](https://github.com/theta42/sso-manager-node/blob/master/API.md). - -[← Back to Home](index.html) diff --git a/docs/concepts-oauth-apps.md b/docs/concepts-oauth-apps.md deleted file mode 100644 index 5479056..0000000 --- a/docs/concepts-oauth-apps.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -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 -in the Directory 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) diff --git a/docs/configuration.md b/docs/configuration.md deleted file mode 100644 index 1e6c36c..0000000 --- a/docs/configuration.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -layout: default -title: Configuration -description: SSO Manager's config layers — conf/base.js defaults, secrets.js overrides, and app_* environment variables. ---- - -# Configuration - -[← Back to Home](index.html) - -The app loads configuration via -[`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf), which -deep-merges, in order (later wins): - -1. `conf/base.js` — committed, generic defaults (`dc=example,dc=com`, - `localhost`, `SSO Manager`). -2. `conf/.js` — optional, environment-specific. -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 splits 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__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string | -| `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__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__issuer=https://sso.example.com` | `conf.oauth.issuer` | string | -| `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number | -| `app_smtp__secure=false` | `conf.smtp.secure` | boolean | -| `app_smtp__host=smtp.example.com` | `conf.smtp.host` | string | -| `app_name=My SSO` | `conf.name` | string | -| `app_redis__host=redis.local` | `conf.redis.host` | string (external Redis) | - -## The `app_*` env layer requires conf >= 1.1.0 - -The `app_*` environment-variable override layer was added in -`@simpleworkjs/conf` **1.1.0**. On 1.0.0 the app ignores all `app_*` vars and only -reads `base.js` / `.js` / `secrets.js`. The Docker image will not honor -`app_*` env on 1.0.0. Refresh the lock from the `nodejs/` directory: - -```bash -cd nodejs && npm install @simpleworkjs/conf@^1.1.0 -``` - -## Inspecting the merged config - -From the `nodejs/` directory: - -```bash -node -e "console.log(require('@simpleworkjs/conf').ldap)" -node -e "console.log(require('@simpleworkjs/conf').oauth)" -node -e "console.log(require('@simpleworkjs/conf'))" # everything -``` - -Or, inside the running container: - -```bash -docker compose exec sso-manager node -e "console.log(require('@simpleworkjs/conf').ldap)" -``` - -`app_*` env vars override `secrets.js`, which overrides `base.js` — if a value -isn't what you expect, check those layers in that order. - -## Migrating an existing instance to the generic defaults - -The committed `nodejs/conf/base.js` 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 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`. - -## Troubleshooting `app_*` env vars - -### `app_*` vars seem to do nothing - -You're on `@simpleworkjs/conf` 1.0.0. Bump to 1.1.0+ (above). - -### 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. - -[← Back to Home](index.html) \ No newline at end of file diff --git a/docs/deployment.md b/docs/deployment.md deleted file mode 100644 index f4cb789..0000000 --- a/docs/deployment.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -layout: default -title: Deployment -description: Deploying SSO Manager — the all-in-one Docker image, bare-metal install, config layers, and backups. ---- - -# Deployment Guide - -[← Back to Home](index.html) - -The full deployment guide — Docker (all-in-one image), bare-metal install, -the `app_*` env reference, backups, and the security notes (including why -LDAPS shouldn't be port-forwarded to the internet) — lives in one place to -avoid two copies drifting out of sync: - -**[DEPLOYMENT.md on GitHub](https://github.com/theta42/sso-manager-node/blob/master/DEPLOYMENT.md)** - -See also [Configuration](configuration.html) for the config layer merge -order, and [LDAP](ldap.html) for the directory layout and connecting a -3rd-party app. - -[← Back to Home](index.html) diff --git a/docs/directory.md b/docs/directory.md deleted file mode 100644 index 1287c6c..0000000 --- a/docs/directory.md +++ /dev/null @@ -1,139 +0,0 @@ ---- -layout: default -title: Directory Management -description: Managing your Home-Lab infrastructure, services, and LDAP access relationships via the SSO Directory API. ---- - -# Directory Management - -The SSO Manager ships with a built-in **Directory & Inventory Management** feature. Instead of just managing bare LDAP groups for your homelab, the Directory allows you to map out your infrastructure graph and assign rich metadata to your services. - -## Architecture - -The Directory models your homelab infrastructure using a parent-child graph (e.g. `Site -> Host -> Service`). - -There are three primary **Kinds** of resources you can define: -- **Site**: A physical location, datacenter, or root node (e.g., `us-east`). Sites do not require parents. -- **Host**: A physical machine, Proxmox node, virtual machine, or LXC container. A Host **must** have a parent Site or another Host. -- **Service (App)**: An application, web service. A Service **must** have a parent Host or another Service. -- **OAuth Integration**: An OAuth 2.0 / OpenID Connect client application. An OAuth integration **must** have a parent Service. - -By defining this hierarchy, the SSO Manager builds a queryable graph of your infrastructure. - -## Automatic LDAP Group Creation - -When you create a new **Host** or **Service** in the Directory via the web UI (or API), the SSO Manager will automatically provision two LDAP groups in your directory to govern access to that resource: - -1. `_access` (Member level access) -2. `_admin` (Owner level access) - -For example, if you create a Service named "Emby" with the slug `app_emby`, the system will create the LDAP groups `app_emby_access` and `app_emby_admin`. You can then assign users to these groups, and they will immediately see the service populate on their "My Services" dashboard. - -## Resource Metadata - -Resources carry a flexible `metadata` JSON object that can store essential context for your applications. The UI natively supports the following metadata fields: - -### Common Metadata -- **Sub Type**: Free-form text to categorize the resource (e.g., `proxmox_node`, `linux`, `lxc`, `web`). -- **IP Address**: The internal IP address of the resource. -- **MAC Address**: The hardware address of the primary interface. -- **Host / URI Address**: The FQDN or URL of the resource (e.g., `https://emby.home.arpa`). -- **Production Environment**: A boolean toggle indicating if the resource is in production. - -### Host Metadata -- **VMID**: The hypervisor VM or Container ID (e.g. `101`). -- **OS**: The operating system name (e.g. `Ubuntu 22.04.3 LTS`). -- **Kernel**: The kernel version string (e.g. `5.15.0-100-generic`). - -### Service Metadata -- **Internal Port**: The local port the service binds to (e.g. `8080`). -- **External Port**: The reverse-proxy or external port (defaults to Internal Port if left blank). -- **Public (No Auth)**: Indicates if the service is exposed publicly without authentication. -- **External Reachable**: Indicates if the service is accessible outside the VPN/local network. -- **Git Repo**: The source code repository for the service (e.g. `https://github.com/...`). -- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`). -- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`). - -### Who sees which metadata - -Metadata keys are declared in `@simpleworkjs/directory-schema` with an `admin` flag, and every API response is passed through its projection. There are three tiers: - -- **Public** — returned to any authenticated caller, including machine (`ServiceToken`) callers: `ip`, `address`, `sshPort`, `fqdn`, `dnsNames`, `port`, `externalPort`, `portMappings`, `isExternalReachable`, `os`, `gitRepo`, `subType`, `icon`, `tagline`, `isPublic`, `isProduction`, `requestable`, `isCurrentSite`. -- **Admin-only** — only for members of `app_sso_directory_admin` / `app_sso_admin`: `vmid`, `macAddress`, `installPath`, `systemdService`, and the OAuth config keys (`redirect_uris`, `scopes`, `allowed_groups`, `token_lifetime`). -- **Never returned** — `client_secret_hash`, plus any key matching `/secret|password|privatekey/i`. Stripped on every path, admins included. - -Note that machine tokens are deliberately *not* admins, so anything a machine consumer needs (the firewall generator reads `port` / `externalPort` / `isExternalReachable`) has to be in the public tier. A metadata key that isn't declared at all is treated as admin-only and will silently vanish for normal users — if you add a field to the admin form, declare it in the schema package too. - -## Catalog & access requests - -The site root (`/`) is the end-user catalog — the only ungated page in the nav. It shows: - -- **My Access** — everything the signed-in user can reach (`GET /api/discovery/me`), each card carrying a **how to reach it** block: the URL for a service, or the SSH invocation for a host. When `directory.jumpHost` is set in the config, host cards render the jump-host form `ssh _-_@`; otherwise they fall back to a direct `ssh @`. -- **Discover More** — everything else in the directory, with a **Request access** button. -- **My Requests** / **Awaiting My Approval** — pending requests, and the approve/deny queue for anyone who owns a requested resource. - -A request is a proposal to join an LDAP group. It targets the resource's `member`-level group (the `_access` one, never `_admin`), and approving it performs the LDAP group add — so LDAP stays the single access-control truth and the table is just the audit trail. Approvals are idempotent: approving for someone already in the group succeeds rather than erroring. - -Requests are decided by the resource's `owner`, or by any directory admin. Mark a resource `metadata.requestable = false` to keep it out of self-service. - -## Navigating the UI - -The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it. - -Directory & inventory list view - -## Slug conventions - -Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention: - -- **Sites**: `site_` — e.g. `site_local`, `site_us-east` -- **Hosts**: `host_` — e.g. `host_pve1`, `host_web01` -- **Services/apps**: a plain slug or `app_` — e.g. `sso-manager`, `app_emby` - -The auto-created LDAP groups derive from the slug (`_access` / `_admin`), so keep slugs stable once access groups are in use. - -## Automatic registration - -You don't have to build the graph by hand — the theta42 tooling registers itself: - -### The stack itself (theta-env) - -[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys: - -- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site -- the **host** the stack runs on (`host_`), with IP, MAC address, OS, and kernel collected from the machine -- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo -- the proxy's auto-registered **OAuth client**, linked under its service - -The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values. - -### Linux hosts (ldap-client) - -The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location. - -## Consumers of the directory - -The inventory graph isn't just documentation — other components read it to make decisions: - -- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that resolves which downstream machines a user may reach from their LDAP groups × the directory's `host` resources (`GET /api/discovery/resources?group=`), then bridges them in. The `host_` slugs and `host__access` groups this directory creates are exactly what it keys off; a host's `metadata.ip` / `metadata.sshPort` tell it where to connect. So a machine registered here (by theta-env or ldap-client) becomes reachable through the jump host the moment a user is in its access group. - -Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9. - -## API - -All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`): - -- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id` -- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations) -- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links -- `GET /api/directory-admin/access-summary` — per-resource group + member counts (the Access column) -- `GET /api/directory-admin/user-access/:uid` — the reverse lookup: every resource a given user can reach, and via which group -- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me` - -Access requests are open to any authenticated user; deciding is gated per-resource inside the router (resource owner or directory admin): - -- `POST /api/access-requests` — `{slug | resourceId, groupCn?, note?}` -- `GET /api/access-requests/mine` — the caller's own history -- `GET /api/access-requests` — pending requests the caller may decide -- `POST /api/access-requests/:id/approve` · `POST /api/access-requests/:id/deny` -- `DELETE /api/access-requests/:id` — the requester withdraws their own pending request diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png deleted file mode 100644 index 18b4a81..0000000 Binary files a/docs/images/dashboard.png and /dev/null differ diff --git a/docs/images/directory.png b/docs/images/directory.png deleted file mode 100644 index 9b1a4b3..0000000 Binary files a/docs/images/directory.png and /dev/null differ diff --git a/docs/images/groups.png b/docs/images/groups.png deleted file mode 100644 index ffcb461..0000000 Binary files a/docs/images/groups.png and /dev/null differ diff --git a/docs/images/oauth-clients.png b/docs/images/oauth-clients.png deleted file mode 100644 index e8790d5..0000000 Binary files a/docs/images/oauth-clients.png and /dev/null differ diff --git a/docs/images/sites.png b/docs/images/sites.png deleted file mode 100644 index 797e8e8..0000000 Binary files a/docs/images/sites.png and /dev/null differ diff --git a/docs/images/users.png b/docs/images/users.png deleted file mode 100644 index 60be54a..0000000 Binary files a/docs/images/users.png and /dev/null differ diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index e6ffcfc..0000000 --- a/docs/index.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -layout: default -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 - -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 instead of a hosted one. - -One place to manage your users and groups, one login (OIDC) your modern apps -can use, and one LDAP directory your older or odder apps can bind to directly. -Everything runs on your own hardware; no phone-home, no hosted control plane, -no per-user pricing. - -Part of the theta42 self-hosted identity stack, alongside -[Proxy](https://theta42.github.io/proxy/) (an OIDC + LDAP-aware reverse proxy) -and [theta-env](https://theta42.github.io/theta-env/) (the two composed with -one command). - -## Screenshots - -Overview dashboard -User list -Groups -Directory & inventory -OAuth client (edit view) - -*(click any screenshot to view full size)* - -## Why this over the alternatives - -Tools like Keycloak, Authentik, Authelia, or Zitadel are OIDC providers, but -LDAP is either a paid feature, a federation target you have to run -separately, or absent. If your stack already has apps that speak LDAP -directly — or you just want one real directory as the source of truth — you -end up running *two* identity systems and keeping them in sync. - -SSO Manager bundles the OpenLDAP directory with the OIDC provider, so OIDC -apps and LDAP apps read from the same users and groups. The trade-off is -scope: it's intentionally small and self-hosted, not an enterprise IAM suite. -If you want a lightweight, self-contained identity provider with a real LDAP -backend, that's the niche. - -## Features - -- **OpenID Connect / OAuth 2.0 provider** — your own access/refresh/ID - tokens; standard discovery document at `/.well-known/openid-configuration`. -- **Bundled OpenLDAP directory** — users, groups, POSIX accounts, SSH public - keys, and sudo roles, with `memberOf` + referential-integrity overlays. -- **Web management UI** — users, groups, and OAuth clients from a browser; - invite and password-reset flows over email; self-service profile + API - tokens. -- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts - via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same - directory. -- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or - 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. -- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/). - -## Get it - -```bash -git clone https://github.com/theta42/sso-manager-node.git -cd sso-manager-node -cp secrets.js.example nodejs/conf/secrets.js # edit it, or use app_* env -docker compose up -d --build -``` - -That's the standalone quick start. For the full set of install options -(Docker, bare-metal, or as part of the combined SSO + proxy stack), the -`app_*` env reference, and the OAuth/LDAP internals, see the -**[GitHub repository](https://github.com/theta42/sso-manager-node)**. - -## Related projects - -- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware - reverse proxy, designed to sit in front of this SSO. -- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that - uses this SSO's directory to decide who may reach which machine. -- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO - Manager and the proxy together with one command. diff --git a/docs/ldap.md b/docs/ldap.md deleted file mode 100644 index dd405bb..0000000 --- a/docs/ldap.md +++ /dev/null @@ -1,432 +0,0 @@ ---- -layout: default -title: LDAP -description: SSO Manager's bundled OpenLDAP directory — schema, service accounts, TLS, and connecting third-party apps directly. ---- - -# LDAP Directory - -[← 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 -authenticates against it over `localhost:389` (inside the all-in-one container) -and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP -directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the -theta42/proxy, etc. - -## Directory layout - -``` -dc=yourdomain,dc=com -├── ou=people users (inetOrgPerson + posixAccount + …) -├── ou=groups groups (groupOfNames) -└── ou=policies password policies (pwdPolicy) - └── cn=ppolicy default policy -``` - -### Users - -User entries are `cn=,ou=people,` and carry the objectClasses: - -- `inetOrgPerson` (cn, sn, mail, …) — identity / contact attrs. -- `posixAccount` (uid, uidNumber, gidNumber, homeDirectory) — the SSO's - `userFilter` is `(objectClass=posixAccount)`, so a user is "a real account" - iff it has `posixAccount`. -- `ldapPublicKey` — SSH public keys (`sshPublicKey`). -- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`). -- `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), -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 -way or use `slappasswd -h '{SSHA512}'`. - -### Groups - -Groups are `cn=,ou=groups,` (`groupOfNames`) with a `member` -attribute listing member DNs. The `memberOf` overlay populates reverse -membership (`memberOf` on the user); `refint` keeps it consistent on -add/remove. - -Note that `groupOfNames` requires **at least one member**, which has two -consequences worth knowing: whoever creates a group is automatically seeded -into it, and removing the last member (user *or* nested group) is refused with -a 409 rather than leaving an invalid entry behind. - -### Nested groups - -A `member` DN may be another group's, not just a user's — that is how nesting -is stored, with no extra schema. Everyone in the nested group is a member of -the outer one, at any depth. Manage it on the **Groups** page under each -group's *Nested* tab, or via the API: - -``` -PUT /api/group/:group/nested/:child nest :child inside :group -DELETE /api/group/:group/nested/:child un-nest -GET /api/group/:group/effective direct users, nested groups, and the - full transitive set of users -``` - -Cycles are refused (409) rather than truncated — a loop makes "who is in this -group" unanswerable. Two standing relationships are wired automatically: the -cross-app `app_super_admin` is nested into every resource's `_admin` -group, and each `_admin` into its `_access` group, so administering -something implies being able to use it. - -**Resolving nesting is a client-side job on stock OpenLDAP.** No 2.6.x release -can evaluate nested groups; `memberOf` and a `(member=X)` filter both return -direct membership only. The bundled slapd is therefore built from source with -the `nestgroup` overlay (see *Modules + overlays* below), and the app is told so -via `ldap.nestedGroupsServerSide`. Against any other server the app computes the -closure itself — same answers, more queries. Either way, **never read `memberOf` -directly to make an access decision**; use `utils/user_groups.js`'s `groupCns()`, -which is correct in both modes. - -### Personal groups - -Every user (person or service account) also gets a **personal Unix group** -at creation — `cn=,ou=groups,`, `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 ``'s -group", admin-only) — add other accounts as supplementary members, e.g. to -share write access to files owned by this group. - -The SSO seeds these groups automatically (entrypoint / `install.sh`): - -| Group | Grants | -|-------|--------| -| `app_super_admin` | cross-app super admin. Nested into the three below, so its members hold those rights transitively rather than by a special case in app code — and the privilege is visible to LDAP-native consumers (SSSD, sudo) too. | -| `app_sso_admin` | full admin (users, groups, settings) | -| `app_sso_oauth_admin` | OAuth client management | -| `app_sso_invite` | invitation management | -| `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below). Deliberately **not** nested into, since it changes how an account is displayed rather than what it may do. | - -## TLS (LDAPS / StartTLS) - -The bundled slapd generates a **self-signed cert** on first start (CN = -`LDAP_CERT_CN`, valid 10y, SAN = CN + `localhost` + `127.0.0.1`) and listens on: - -- `ldaps:///` — **636**, TLS (the port to expose for direct-LDAP clients). -- `ldap:///` — **389**, plain + StartTLS (not mapped to the host by default). - -The cert lives on the `ldap-certs` volume so it persists across container -recreation. - -### Trusting the self-signed cert - -Copy it out and add it to the client's CA store: - -```bash -docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt -``` - -…or, for quick LAN use, set `TLS_REQCERT never` on the client (the theta42/proxy -sets `app_ldap__tlsOptions__rejectUnauthorized=false` for the same effect). - -### Using your own cert - -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). - -## 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 - -A service account is a normal `posixAccount` for something that isn't a -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. - -Create one from the **Users → Service Accounts** tab'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 flagged (via membership in the `app_sso_service_account` -group) so it's listed separately from real people and excluded from "all -users" notification broadcasts. - -Email and password are both optional for a service account: - -- No `mail` is set unless you give it one (it never needs a mailbox). -- 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). - -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 -ldapsearch -x -H ldaps://sso.example.com:636 \ - -D "cn=ldapclient,ou=people,dc=yourdomain,dc=com" -W \ - -b "ou=people,dc=yourdomain,dc=com" '(objectClass=posixAccount)' cn mail -``` - -## Connecting a 3rd-party app or container - -Most self-hosted apps with an "LDAP authentication" settings page — Gitea, -Nextcloud, Grafana, Emby, Jenkins, etc. — or containers configured via -`LDAP_*` env vars, all ask for the same handful of values. These are the -`conf.ldap` values from [Configuration](configuration.html), applied to -*your* domain: - -| Field the app asks for | Value | -|---|---| -| Host / URL | `ldaps://:636` (preferred), or `ldap://:389` + StartTLS | -| Bind DN | a dedicated service account — e.g. `cn=ldapclient,ou=people,` (see above) | -| Bind password | that service account's password | -| User search base | `ou=people,` | -| User search filter | `(objectClass=posixAccount)` | -| Username attribute | `uid` | -| Email attribute | `mail` | -| Group search base | `ou=groups,` | -| Group membership attribute | `memberOf` (on the user entry — populated by the `memberof` overlay) | -| TLS | required for 636 (LDAPS); if using the bundled self-signed cert, either trust it (see *TLS* above) or set the app's "don't verify cert" option for LAN-only use | - -### Worked example: Gitea - -Gitea's **Admin → Authentication Sources → Add Authentication Source** (type -LDAP, "Bind DN/Password") maps directly: - -- Security Protocol: `LDAPS` -- Host / Port: your SSO host / `636` -- Bind DN: `cn=ldapclient,ou=people,dc=yourdomain,dc=com` -- Bind Password: the service account's password -- User Search Base: `ou=people,dc=yourdomain,dc=com` -- User Filter: `(&(objectClass=posixAccount)(uid=%s))` -- Username Attribute: `uid` -- E-mail Attribute: `mail` - -Other apps with an LDAP settings UI follow the same shape — the field names -above are the constants; only the base DN and hostname change per deployment. - -### Generic Docker container (`LDAP_*` env vars) - -For images that take a flat env-var LDAP config (there's no single standard, -but most look like this): - -```yaml -environment: - LDAP_URL: ldaps://sso.example.com:636 - LDAP_BIND_DN: cn=ldapclient,ou=people,dc=yourdomain,dc=com - LDAP_BIND_PASSWORD: - LDAP_USER_BASE: ou=people,dc=yourdomain,dc=com - LDAP_USER_FILTER: (objectClass=posixAccount) - LDAP_GROUP_BASE: ou=groups,dc=yourdomain,dc=com -``` - -Check the specific image's docs for its actual variable names — the values -you plug in are still the ones from the table above. - -### Full Linux host auth (SSH, sudo, login) instead of a single app - -If you want a *host* (not just one app) to authenticate logins, SSH keys, and -sudo against this LDAP directory — not just one application — that's a -different integration (SSSD + PAM + NSS, not a single bind). See -[theta42/ldap-client](https://github.com/theta42/ldap-client): a script that -configures SSSD on Ubuntu/Debian hosts against this directory, including -group-based access control and SSH public key retrieval from LDAP. - -## Modules + overlays (external LDAP servers) - -If you point the app at your own LDAP server instead of the bundled slapd, it -needs: - -- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`), - `ppolicy`, `memberof`, `refint`. -- **Optional — `nestgroup`:** server-side nested-group evaluation. Not in any - released OpenLDAP (added to master as ITS#10161 in March 2024; 2.7 is still - unreleased), so the bundled image builds slapd from a pinned upstream commit. - Without it the app resolves nesting itself and everything still works — leave - `ldap.nestedGroupsServerSide` at `false`. With it, set that to `true` and - configure: - - ``` - overlay nestgroup - nestgroup-base ou=groups, - nestgroup-flags member-filter memberof-filter memberof-values - ``` - - Flags are **space-separated**; the comma form the man page's `{a, b, c}` - notation suggests is rejected. `member-values` is deliberately omitted — it - expands the `member` attribute when reading a group, which destroys the - distinction between "listed here" and "reachable through a nested group", and - the raw values are then unrecoverable. Transitive answers come from the filter - flags and from `GET /api/group/:group/effective`. - - One more consequence of building from master: it ships **LMDB 1.0.0**, whose - on-disk format is mutually unreadable with the 0.9.x in 2.6.x - (`MDB_INVALID: File is not an LMDB file`). Moving a directory between the two - is a `slapcat` → `slapadd` reload, not a restart. -- **Custom schema:** the `theta42Person` auxiliary objectClass with - `dateOfBirth` — 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`, `app_sso_invite`, `app_sso_oauth_admin`, - and `app_super_admin` (the cross-app super-admin group; the bundled entrypoint - also nests it into the first three). - -`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). - -## Backups and restore - -`ops/backup.sh` automates this (LDAP + Redis + `./config/`, with retention) -for standalone deployments — see the *Backups and restore* section of -`DEPLOYMENT.md`. The manual LDAP-only steps below are what it does under the -hood, useful if you want just the directory without Redis/config. - -**Backup** (while slapd is running): - -```bash -docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ - -b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif -``` - -Store the `.ldif` off the host — it contains every user's password hash. - -**Restore** into a stopped directory. The SSO image uses a static `slapd.conf` -(slapd starts with `-f`, not cn=config `-F`), so restore uses `slapadd -f`: - -```bash -docker compose stop sso-manager -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 -``` - -Verify: `docker compose exec sso-manager ldapsearch -x -b "dc=yourdomain,dc=com"`. - -Redis state (OAuth clients, tokens) and `./config/` secrets are backed up -separately — see the *Backups and restore* section of `DEPLOYMENT.md` for the -full (LDAP + Redis + secrets) runbook. - -## 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`: - -```bash -sudo ./ops/ldap-setup.sh -p 'admin-password' -b dc=yourdomain,dc=com -``` - -### 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 -``` - -[← Back to Home](index.html) \ No newline at end of file diff --git a/docs/oauth.md b/docs/oauth.md deleted file mode 100644 index d1b80b5..0000000 --- a/docs/oauth.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -layout: default -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 - -[← 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 -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 -can be both your SSO and your user directory at once. - -## Discovery - -The provider publishes a standards-compliant discovery document: - -``` -GET https:///.well-known/openid-configuration -``` - -It advertises the `issuer`, `authorization_endpoint`, `token_endpoint`, -`userinfo_endpoint`, `end_session_endpoint`, supported scopes, and token -lifetimes. OIDC clients (e.g. the theta42/proxy) can read their endpoint URLs -from here rather than configuring each one. - -The `issuer` advertised is `conf.oauth.issuer` — set it to the **browser-facing** -HTTPS URL the SSO is served at (e.g. `https://sso.example.com`), either in -`conf/secrets.js` or via `app_oauth__issuer` / `OAUTH_ISSUER`. - -## OAuth clients - -An OAuth client represents an app that authenticates against the SSO. Each has: - -- `client_id` (UUID) + `client_secret` (bcrypt-hashed; the **raw secret is - shown once** when the client is created or rotated — save it immediately). -- `name`, `description`, `created_by` (the admin uid that created it). -- `redirect_uris` — allowed callback URLs. Each entry matches exactly, or may - use `*` (one hostname label) / `**` (any number of labels) as a wildcard — - e.g. `https://*.example.com/__proxy_auth/callback` covers every host - theta42/proxy fronts under `example.com`, so you don't have to register - each proxied host's callback individually. -- `scopes` — requested scopes (default `openid profile email groups`). -- `allowed_groups` — restrict the client to members of specific SSO groups - (empty = any valid user). -- `token_lifetime` — `access_token` / `refresh_token` lifetimes (seconds). - -### Managing clients - -Clients are managed directly from the **Directory** tab in the web UI. They are modeled as resources of `kind: oauth` and must belong to a parent Service. - -| Action | How to do it | -|--------|--------------| -| **Create** | Click the green **+** on a parent Service to add a child resource. Choose **OAuth Integration**. The raw `client_secret` is shown once upon creation. | -| **Edit** | Click the edit pencil on the OAuth resource in the Directory list or tree. You can update redirect URIs, scopes, allowed groups, and token TTLs. | -| **Delete** | Click the trash can on the OAuth resource in the Directory list. | -| **Rotate Secret** | Open the edit modal for the OAuth resource and click **Rotate Client Secret**. The new raw secret is shown once. | - -> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group. - -Editing an OAuth client resource - -## Scopes - -| Scope | Claims / access | -|-------|-----------------| -| `openid` | OIDC ID token + discovery | -| `profile` | `preferred_username`, display name, etc. | -| `email` | the user's `mail` | -| `groups` | the user's group memberships (the `groups` claim) | - -The `groups` claim is what relying parties (e.g. the proxy's -`app_auth__adminGroups`) use to map group membership to roles. - -## Token lifetimes - -Defaults (overridable per-client via `token_lifetime`, or globally via -`app_oauth__token_lifetime__access_token` / -`app_oauth__token_lifetime__refresh_token`): - -- access token: 3600s (1 hour) -- refresh token: 2592000s (30 days) - -## Admin gating - -SSO admin actions are gated by LDAP group membership (checked via the group's -`member` list, not `memberOf` on the user): - -- `app_sso_admin` — full admin (users, groups, settings). -- `app_sso_oauth_admin` — OAuth client management. -- `app_sso_invite` — invitation management. - -The bootstrap in [theta-env](https://github.com/theta42/theta-env) creates your -first admin and adds them to `app_sso_admin` + `app_sso_oauth_admin` -automatically; for a standalone install, add the admin's DN to those groups -manually (or via `ops/ldap-setup.sh`). - -## JWT signing - -Tokens are signed with `conf.oauth.jwtSecret` (`app_oauth__jwtSecret` / -`JWT_SECRET`). **Persist this secret** — if it changes, every issued token -stops validating. The all-in-one Docker image auto-generates one if none is set, -but that generated value does not survive container recreation unless you -persist it (set `JWT_SECRET` in your `.env`). - -[← Back to Home](index.html) \ No newline at end of file diff --git a/docs/replication.md b/docs/replication.md deleted file mode 100644 index 06955ad..0000000 --- a/docs/replication.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -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. diff --git a/docs/robots.txt b/docs/robots.txt deleted file mode 100644 index 9bfccd1..0000000 --- a/docs/robots.txt +++ /dev/null @@ -1,4 +0,0 @@ -User-agent: * -Allow: / - -Sitemap: https://theta42.github.io/sso-manager-node/sitemap.xml diff --git a/docs/vault.md b/docs/vault.md deleted file mode 100644 index a301682..0000000 --- a/docs/vault.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -layout: default -title: Secrets Vault -nav_order: 6 ---- - -# Secrets Vault - -SSO Manager integrates natively with **OpenBao** (a Vault fork) to securely manage and store sensitive data, configuration, and API keys. - -The Vault proxy endpoint is exposed directly through SSO Manager at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container. - -## Architecture - -The secrets engine uses a persistent file backend (`/var/lib/docker/volumes/theta-env_openbao-data/_data`) to ensure high availability and durability. - -When the environment is initialized via `setup.sh`, OpenBao is automatically unsealed and seeded with a root token that the application uses for authentication. The root token is kept securely inside the container environment. - -## Accessing the Vault - -The SSO Manager Vault can be accessed in two ways: - -1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly. SMTP and OAuth settings are edited through structured form fields (not a raw JSON blob) and saved to OpenBao at `secret/sso-manager/conf` at runtime, taking effect immediately. Secret fields — the SMTP password and the OAuth JWT secret — are returned masked (`********`); leave the field unchanged (or blank) to keep the stored value, or enter a new value to replace it. -2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token. - -### API Example - -To read secrets from the default key-value store, issue a `GET` request to: -`/api/vault/v1/secret/data/sso-manager/conf` - -Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints. - -## Namespaces and Paths - -Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using the `kv-v2` backend. When configurations are edited via the admin UI, SSO Manager performs a deep-merge so that partial updates don't overwrite unrelated keys (such as SMTP vs OAuth configurations). - -## Plugin Integration - -Plugin instances store their per-instance secrets in OpenBao at -`secret/plugins//conf` (configured, loaded/unloaded, and run from -the **Plugins** page — see [Plugins](plugins.html)). The plugin process runs -in-process, so the SSO Manager reads/writes those secrets server-side through -the `sso-broker` token; the admin UI only ever sees masked values, and external -apps can retrieve API tokens via the `/api/vault` proxy to keep permissions -consistently enforced instead of hardcoding them.