diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..61dc586 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,45 @@ +# Git +.git +.gitignore + +# Development +.claude +*.md +# README.md and tos.md are both read at runtime (tos.md is loaded by +# routes/index.js at boot), so they must stay in the build context. +!README.md +!tos.md + +# Tests +nodejs/tests/ +nodejs/*.test.js + +# Host dependency tree — let the image run a clean `npm ci`. Also avoids +# copying platform-wrong native modules (e.g. bcrypt built for the host OS). +nodejs/node_modules + +# IDE +.vscode +.idea +*.swp +*.swo + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Docker (prevent recursive copy) +Dockerfile* +docker-compose.yml +.dockerignore + +# Ops scripts (not needed in container) +ops/ + +# Secrets (mount at runtime instead) +nodejs/conf/secrets.js +secrets.js diff --git a/.gitignore b/.gitignore index e901d22..56c7ca0 100755 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,9 @@ ops/cookbooks/vendor secrets.json secrets.js + +# Jekyll build artifact (GitHub Pages builds remotely; ignore locally) +docs/_site + +# Jekyll build artifact (GitHub Pages builds remotely; ignore locally) +docs/_site diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..32d6df6 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,339 @@ +# Deployment Guide — SSO Manager + +Two supported deployment methods: + +1. **Docker** — a single all-in-one image bundling the app + OpenLDAP (`docker compose up`). +2. **Bare metal** — `install.sh` on Debian/Ubuntu (installs Node.js, OpenLDAP, 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_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 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 + +```bash +# Minimal: set an LDAP admin password and a JWT secret, then build + start. +LDAP_ADMIN_PASS='choose-a-strong-password' \ +JWT_SECRET="$(openssl rand -hex 32)" \ +docker compose up -d --build +``` + +For a customized deployment, put the overrides in a `.env` file next to +`docker-compose.yml`: + +```env +LDAP_BASE_DN=dc=yourdomain,dc=com +LDAP_DOMAIN=yourdomain.com +LDAP_ADMIN_PASS=your-admin-password +ORG_NAME=Your Org +JWT_SECRET=your-jwt-secret +OAUTH_ISSUER=https://sso.yourdomain.com # browser-facing URL the proxy serves +LDAP_CERT_CN=sso.yourdomain.com # hostname LDAPS clients verify against +SMTP_HOST=smtp.yourdomain.com +SMTP_PORT=587 +SMTP_USER=noreply@yourdomain.com +SMTP_PASS=your-smtp-password +SMTP_FROM=Your Org +PORT=3001 +LDAPS_PORT=636 +# LDAP_PORT=389 # uncomment the 389 host mapping in compose if you need plain LAN binds +``` + +Then `docker compose up -d --build`. + +### What the entrypoint does + +`docker-entrypoint.sh` (run as the container entrypoint): + +1. 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). +2. 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`) — idempotently, so container restarts are safe. +3. Starts a bundled Redis (the app uses `model-redis` for models/sessions), unless + `app_redis__host` is set (then it's expected to be external). +4. Exports `app_*` env vars so the app binds to the local slapd (any `app_*` you + set in the compose environment wins over the entrypoint's defaults). +5. `exec`s `node bin/www`. + +### Access + +- SSO Manager UI: `http://localhost:3001` (HTTP inside the container — put a TLS-terminating proxy in front for browser access) +- Health check: `http://localhost:3001/health` → `{"status":"ok"}` +- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration` +- LDAP (internal, app↔slapd): `ldap://localhost:389` (not mapped to the host) +- LDAPS (for legacy apps / direct binds): `ldaps://:636` (TLS) + +### Available environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `LDAP_BASE_DN` | `dc=example,dc=com` | slapd suffix + app user/group base | +| `LDAP_DOMAIN` | derived from `LDAP_BASE_DN` | DNS domain; default for `LDAP_CERT_CN` and OAuth issuer | +| `LDAP_ADMIN_PASS` | `admin` | slapd root password + app bind password | +| `ORG_NAME` | `SSO Manager` | org name in UI/email/group descriptions | +| `JWT_SECRET` | auto-generated | OAuth JWT signing secret (persist it!) | +| `OAUTH_ISSUER` | `https://sso.` | OIDC issuer in the discovery doc (browser-facing URL) | +| `LDAP_CERT_CN` | `LDAP_DOMAIN` | CN/SAN on the LDAPS cert (hostname clients verify against) | +| `LDAP_CERT_DIR` | `/etc/openldap/certs` | where the entrypoint looks for `ldap.crt`+`ldap.key` (mount your own here) | +| `SMTP_HOST`/`SMTP_PORT`/`SMTP_USER`/`SMTP_PASS`/`SMTP_FROM` | localhost / 587 / empty | outbound email | +| `PORT` | `3001` | host port mapped to the UI | +| `LDAPS_PORT` | `636` | host port mapped to LDAPS | +| `LDAP_PORT` | `389` | uncomment the host mapping in compose to expose plain LDAP (not recommended) | + +Any `app_*` var may also be set directly to override any config value (see the +table at the top). + +### LDAP TLS (LDAPS / StartTLS) + +The bundled slapd generates a **self-signed cert** on first start (CN = `LDAP_CERT_CN`, +valid 10 years, SAN includes the CN + `localhost` + `127.0.0.1`) and listens on +`ldaps:///` (636) plus offers StartTLS on `ldap:///` (389). The cert is stored on the +`ldap-certs` volume so it persists across container recreation — clients don't need +to re-trust on every rebuild. + +- **Trusting the self-signed cert** (clients): copy `/etc/openldap/certs/ldap.crt` + out of the container and add it to the client's trusted CA store, or set + `TLS_REQCERT never` for quick-and-dirty LAN use. Fetch it with: + ```bash + docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt + ``` +- **Use your own cert** (CA-signed / internal CA): replace the `ldap-certs` named + volume with a bind mount containing your own `ldap.crt` + `ldap.key`: + ```yaml + volumes: + - ./certs:/etc/openldap/certs # must contain ldap.crt + ldap.key + ``` + The entrypoint leaves existing certs untouched (idempotent). + +> Port 389 (plain LDAP) is **not** mapped to the host by default, to avoid cleartext +> password binds over the LAN. Direct-LDAP clients should use LDAPS (636) or +> StartTLS. Uncomment the `389` mapping in `docker-compose.yml` only if you need +> plain LAN binds and accept the risk. + +### Fronting with a reverse proxy (theta42/proxy) + +The SSO Manager runs HTTP inside the container; terminate TLS at a front proxy. +The [`theta42/proxy`](https://github.com/theta42/proxy) is an OIDC-protected reverse +proxy and a natural fit — it's both an **OIDC client** of the SSO Manager *and* a +**direct LDAP client** for user lookups. To run both together: + +1. **Put them on one Docker network** so the proxy can reach the SSO Manager + internally at `http://sso-manager:3001` for token/userinfo (server-to-server), + without exposing the SSO Manager's HTTP port to the internet: + ```yaml + # in the proxy's compose, or a shared external network: + networks: + - sso-net + ``` +2. **Set the SSO's `OAUTH_ISSUER`** to the *browser-facing* HTTPS URL the proxy + serves the SSO at (e.g. `https://sso.yourdomain.com`). The proxy's + `oidc.issuer`/endpoints must match — it can get them from the SSO's + `/.well-known/openid-configuration`. Server-to-server calls from the proxy go to + the internal `http://sso-manager:3001` URL; only the issuer/redirect URLs must + be public. +3. **Register the proxy as an OAuth/OIDC client** in the SSO Manager UI, with a + `redirectUri` matching the proxy's callback (e.g. + `https://proxy.yourdomain.com/api/auth/oidc/callback`), and put the client + secret in the proxy's `secrets.js`. +4. **LDAP for the proxy**: point the proxy's `ldap.url` at + `ldaps://sso-manager:636` (TLS, same Docker network) rather than a LAN IP, and + create a dedicated LDAP service account under `ou=people` (e.g. + `cn=ldapclient,ou=people,…`) via the SSO Manager UI — don't reuse the admin DN. + +### Backups (small-business / ~100 users) + +- **LDAP data** lives on the `ldap-data` volume (`/var/lib/ldap` in the container). + Back up the directory with an `ldapsearch`/`slapcat` export on a schedule: + ```bash + docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ + -b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif + ``` + (Restorable with `ldapadd`/`ldapmodify` against a fresh instance.) +- **Redis** is in-memory and not persisted by default (session/cache only — safe + to lose). If you want session durability, mount a Redis AOF/RDB volume and + enable persistence in `docker-entrypoint.sh`. +- **JWT_SECRET** and **LDAP_ADMIN_PASS** are operational secrets — store them + outside the container (your `.env`, a password manager, etc.). + +--- + +## Method 2: Bare metal (Debian/Ubuntu) + +`install.sh` is an idempotent installer: it installs Node.js 20.x, installs and +configures OpenLDAP (modules + overlays + custom schema + directory tree + +required groups), deploys the app to `/opt/sso-manager`, and creates a systemd +unit. Configuration is written to `/opt/sso-manager/conf/secrets.js` (file-based). + +### Prerequisites + +- Debian 11+ / Ubuntu 20.04+ +- Root (`sudo`) +- Internet access + +### Install + +```bash +sudo ./install.sh \ + -p 'your-ldap-password' \ + -b 'dc=yourdomain,dc=com' \ + -n 'Your Org' \ + -o 3001 +``` + +| Flag | Env var | Description | +|------|---------|-------------| +| `-p, --admin-pass` | `LDAP_ADMIN_PASS` | LDAP admin password (required) | +| `-b, --base-dn` | `LDAP_BASE_DN` | Base DN (default `dc=example,dc=com`) | +| `-n, --org-name` | `ORG_NAME` | Org name (default `SSO Manager`) | +| `-o, --port` | `PORT` | HTTP port (default `3001`) | +| `-j, --jwt-secret` | `JWT_SECRET` | JWT secret (default auto-generated) | +| `-s, --smtp-config` | `SMTP_*` | SMTP as `host:port:user:pass` | +| `--skip-ldap` | `SKIP_LDAP` | Skip LDAP setup (use existing) | +| `--skip-app` | `SKIP_APP` | LDAP setup only | +| `--dry-run` | `DRY_RUN` | Show actions without making changes | + +### Post-install + +```bash +sudo systemctl enable --now sso-manager +journalctl -fu sso-manager +curl http://localhost:3001/health # -> {"status":"ok"} +``` + +### What `install.sh` does + +1. Installs Node.js 20.x (NodeSource). +2. 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. +3. Installs the app to `/opt/sso-manager` and runs `npm ci --omit=dev`. +4. Generates `conf/secrets.js` (LDAP/SMTP/JWT) and `conf/base.js` (generic defaults). +5. Installs `sso-manager.service` (systemd), enabled on boot. + +> For an existing LDAP server, run `sudo ./install.sh --skip-ldap …` and point the +> app at it. For LDAP-only setup on a host that already runs the app elsewhere, use +> `--skip-app`. To (re)configure overlays on an already-installed slapd, prefer +> `ops/ldap-setup.sh` (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). + +`ops/ldap-setup.sh -p ` configures all of the above idempotently +against a running slapd (auto-detects the database holding your base DN, and +verifies `pwdAccountLockedTime` is live — the attribute the app's +active/inactive toggle depends on). + +--- + +## Migrating an existing instance to the generic defaults + +The committed `nodejs/conf/base.js` now ships **generic** defaults +(`dc=example,dc=com`, `localhost`, `SSO Manager`). Previously it carried +Theta42-specific values (LDAP bind DN/bases, SMTP host/user/sender, OAuth issuer). +If you run an existing instance off this repo: + +- Move those per-deployment, non-secret values (bind DN, user/group bases, SMTP + host/user/sender, OAuth issuer, org name) from `base.js` into your gitignored + `conf/secrets.js`, **or** set them as `app_*` env vars. Secret values (LDAP bind + password, SMTP password, JWT secret) already belong in `secrets.js`. +- After the change, verify the merged config: `node -e "console.log(require('@simpleworkjs/conf'))"` from the `nodejs/` directory. + +--- + +## Troubleshooting + +### `503 OpenLDAP ppolicy overlay is not configured` +The ppolicy overlay isn't attached to the database holding your users, so the +active/inactive toggle can't set `pwdAccountLockedTime`. Run: +```bash +sudo ./ops/ldap-setup.sh -p 'admin-password' -b dc=yourdomain,dc=com +``` + +### App starts but LDAP operations 401 / "Invalid Credentials" +Check the merged LDAP config the app actually sees: +```bash +cd nodejs && node -e "console.log(require('@simpleworkjs/conf').ldap)" +``` +Confirm `url`/`bindDN`/`bindPassword`/`userBase` match your directory. Remember +`app_*` env vars override `secrets.js` which overrides `base.js`. + +### `app_*` env vars seem to do nothing +You're on `@simpleworkjs/conf` 1.0.0. Bump to 1.1.0+: +```bash +cd nodejs && npm install @simpleworkjs/conf@^1.1.0 +``` + +### LDAP connection refused +```bash +docker compose exec sso-manager sh -c 'ldapsearch -x -H ldap://localhost:389 -b "" -s base' +systemctl status slapd # bare metal +netstat -tlnp | grep 389 +``` + +--- + +## Security notes + +1. **Never commit `secrets.js`** — it's in `.gitignore`. +2. **Use LDAPS / StartTLS** for any LDAP connection that crosses the network. The + bundled slapd listens on `ldaps:///` (636, TLS) and `ldap:///` (389, plain + + StartTLS); port 389 is not mapped to the host by default so LAN clients can't + bind in cleartext. Direct-LDAP apps (legacy services, `theta42/proxy`) should + use `ldaps://…:636` or StartTLS. +3. **Persist `JWT_SECRET`** — if the Docker image auto-generates one and you don't + set `JWT_SECRET`, issued tokens invalidate on container recreation. +4. **Don't expose the UI's HTTP port to the internet** — terminate TLS at a front + proxy and keep `3001` on the Docker network / localhost only. +5. 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/Dockerfile.openldap b/Dockerfile.openldap new file mode 100644 index 0000000..4268153 --- /dev/null +++ b/Dockerfile.openldap @@ -0,0 +1,87 @@ +# Theta42 SSO Manager with OpenLDAP - All-in-One Dockerfile +# App + OpenLDAP in a single container, for development/testing or a +# self-contained single-node deployment. For production, run a dedicated +# LDAP server and configure the app via app_* env vars / mounted secrets.js. + +FROM node:20-alpine + +# Install OpenLDAP and required packages. +# Alpine splits OpenLDAP into many small subpackages; there is no catch-all +# "openldap-overlays" package. We install exactly the backends/overlays/modules +# the app depends on: +# openldap-back-mdb : the mdb backend (slapd.conf uses `database mdb`) +# openldap-overlay-ppolicy : ppolicy module + overlay (account locking) +# openldap-overlay-memberof : reverse group membership +# openldap-overlay-refint : referential integrity on group members +# openldap-passwd-sha2 : pw-sha2 module ({SSHA512} user password hashing) +# Note: Alpine does NOT ship a ppolicy.schema file — on OpenLDAP 2.6 the ppolicy +# schema is built into ppolicy.so and registered when the module loads, so +# docker-entrypoint.sh loads it via `moduleload ppolicy` (no schema include). +# openssl : used by docker-entrypoint.sh to generate a JWT secret +RUN apk add --no-cache \ + openldap \ + openldap-clients \ + openldap-back-mdb \ + openldap-overlay-ppolicy \ + openldap-overlay-memberof \ + openldap-overlay-refint \ + openldap-passwd-sha2 \ + dumb-init \ + bash \ + openssl \ + redis \ + && rm -rf /var/cache/apk/* + +# The openldap package already creates the `ldap` user/group, which slapd runs +# as (see -u ldap -g ldap in docker-entrypoint.sh). Nothing to add here. + +WORKDIR /app + +# Create required directories. slapd runs as the ldap user; the app process +# runs as root in this image (matches the bare-metal systemd unit). +RUN mkdir -p /var/lib/ldap /etc/ldap/sasl2 && \ + chown -R ldap:ldap /var/lib/ldap /etc/ldap/sasl2 + +# Copy application source and install production dependencies. +# .dockerignore excludes nodejs/node_modules so npm ci builds a clean tree. +COPY nodejs/package*.json ./ +RUN npm ci --omit=dev + +COPY nodejs/app.js ./ +COPY nodejs/bin ./bin +COPY nodejs/conf ./conf +COPY nodejs/controller ./controller +COPY nodejs/middleware ./middleware +COPY nodejs/models ./models +COPY nodejs/routes ./routes +COPY nodejs/utils ./utils +COPY nodejs/views ./views +COPY nodejs/public ./public + +# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the +# app flattened into /app, __dirname is /app/routes and ../../ resolves to /, +# so the file must exist at /tos.md (mirroring the repo where tos.md sits one +# level above the nodejs/ app dir). Without this the app crashes on startup. +COPY tos.md /tos.md + +# Copy startup script +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Expose ports +# 3001: SSO Manager web interface (HTTP — terminate TLS at the front proxy) +# 389: LDAP (plain + StartTLS) — used internally by the app; map to host only +# if you want LAN clients to bind without TLS (not recommended). +# 636: LDAPS — for legacy apps / direct LDAP binds over the network (TLS) +EXPOSE 3001 389 636 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1 + +# dumb-init reaps zombies and forwards signals to the node process the +# entrypoint execs into. Without it SIGTERM from `docker stop` is ignored +# and the container hits the 10s kill timeout. +ENTRYPOINT ["dumb-init", "/usr/local/bin/docker-entrypoint.sh"] + +CMD ["node", "bin/www"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4620b10 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,93 @@ +# Docker Compose for the SSO Manager all-in-one image (app + OpenLDAP in one container). +# +# The image (Dockerfile.openldap) bundles a slapd that the app talks to over +# localhost. Configuration is supplied to the app via `app_*` environment +# variables — the highest-precedence config layer in @simpleworkjs/conf. Any +# `app_*` var not set here falls back to a sensible default baked into +# docker-entrypoint.sh (e.g. app_ldap__url -> ldap://localhost:389). +# +# Copy this file to .env (or export the vars) to set the secrets below, or +# set them inline on the command line: +# LDAP_ADMIN_PASS=... JWT_SECRET=... docker compose up -d +# +# Requires @simpleworkjs/conf >= 1.1.0 in the image (env overrides). Refresh +# nodejs/package-lock.json with `npm install @simpleworkjs/conf@^1.1.0` before +# building, so the image actually contains the env-override feature. + +services: + sso-manager: + build: + context: . + dockerfile: Dockerfile.openldap + container_name: sso-manager + restart: unless-stopped + ports: + # SSO Manager web UI (HTTP inside the container; terminate TLS at the + # front proxy — e.g. the theta42/proxy). Don't expose 3001 to the open + # internet; bind it to localhost or leave it on the docker network only. + - "${PORT:-3001}:3001" + # LDAPS — direct LDAP binds from legacy apps / the proxy over the network + # (TLS, self-signed cert by default; mount your own at LDAP_CERT_DIR). + - "${LDAPS_PORT:-636}:636" + # LDAP plain (389) is NOT mapped to the host by default — it would allow + # cleartext password binds over the LAN. Uncomment to permit StartTLS or + # plain binds from the LAN (not recommended): + # - "${LDAP_PORT:-389}:389" + environment: + # ── LDAP server-side (configures the bundled slapd) ── + - LDAP_BASE_DN=${LDAP_BASE_DN:-dc=example,dc=com} + - LDAP_DOMAIN=${LDAP_DOMAIN:-example.com} + - LDAP_ADMIN_PASS=${LDAP_ADMIN_PASS:-admin} # slapd root + app bind password + - ORG_NAME=${ORG_NAME:-SSO Manager} + # CN on the LDAP TLS cert. Clients verify against this hostname. Leave + # empty to default to LDAP_DOMAIN; set to the public hostname clients + # connect over (computed in docker-entrypoint.sh, not here, to avoid + # nested-variable interpolation limitations in compose v1). + - LDAP_CERT_CN=${LDAP_CERT_CN:-} + + # ── App config overrides (app_* -> @simpleworkjs/conf) ── + # Defaults come from docker-entrypoint.sh; set these to override. + # - app_oauth__issuer=https://sso.example.com + # - app_ldap__url=ldap://localhost:389 # default; points at bundled slapd + # - app_ldap__bindDN=cn=admin,${LDAP_BASE_DN} + # - app_ldap__bindPassword=${LDAP_ADMIN_PASS} + # - app_ldap__userBase=ou=people,${LDAP_BASE_DN} + # - app_ldap__groupBase=ou=groups,${LDAP_BASE_DN} + - app_oauth__jwtSecret=${JWT_SECRET} # falls back to an auto-generated secret + # OIDC issuer advertised in /.well-known/openid-configuration. This is the + # browser-facing URL the front proxy serves the SSO at — the theta42/proxy + # (an OIDC client) and other apps use it for discovery. Server-to-server + # token/userinfo calls from the proxy can go to http://sso-manager:3001 + # over the docker network; only the issuer/redirect URLs must be public. + # Leave empty to default to https://sso. (set in the entrypoint). + - app_oauth__issuer=${OAUTH_ISSUER:-} + - app_name=${ORG_NAME:-SSO Manager} + + # ── Optional SMTP (outbound email) ── + - app_smtp__host=${SMTP_HOST:-localhost} + - app_smtp__port=${SMTP_PORT:-587} + - app_smtp__user=${SMTP_USER:-} + - app_smtp__pass=${SMTP_PASS:-} + - app_smtp__from=${SMTP_FROM:-SSO Manager } + + - NODE_ENV=production + - NODE_PORT=3001 + volumes: + # Persist the LDAP database across container recreation. + - ldap-data:/var/lib/ldap + # Persist the auto-generated self-signed TLS cert so clients don't have to + # re-trust it on every rebuild. To use your own CA-signed cert instead, + # replace this with a bind mount of your cert dir, e.g.: + # - ./certs:/etc/openldap/certs + # (must contain ldap.crt + ldap.key; the entrypoint leaves them untouched). + - ldap-certs:/etc/openldap/certs + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + +volumes: + ldap-data: + ldap-certs: \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..e898f90 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,312 @@ +#!/usr/bin/env bash +# docker-entrypoint.sh — initialize the bundled OpenLDAP and start SSO Manager. +# Used by Dockerfile.openldap (the all-in-one image: app + slapd in one container). +# +# This is intended for development/testing / single-node deployments. For +# production, run a dedicated LDAP server and point the app at it via app_* +# env vars (or a mounted conf/secrets.js) using the app-only image. +# +# The app reads its configuration from conf/base.js + conf/secrets.js, deep-merged +# by @simpleworkjs/conf, with `app_*` environment variables as the +# highest-precedence override layer. This entrypoint exports those `app_*` +# vars so the app connects to the bundled slapd without any mounted secrets +# file. Any `app_*` var already set in the environment wins (the values below +# are defaults/fallbacks only). + +set -e + +# ── LDAP server-side configuration ────────────────────────────────────────── +LDAP_BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}" +LDAP_ADMIN_PASS="${LDAP_ADMIN_PASS:-admin}" +# Derive the DNS domain from the base DN (dc=foo,dc=bar -> foo.bar) unless given. +if [[ -z "${LDAP_DOMAIN:-}" ]]; then + LDAP_DOMAIN=$(echo "$LDAP_BASE_DN" | sed 's/^dc=//; s/,dc=/./g') +fi +ORG_NAME="${ORG_NAME:-SSO Manager}" +LDAP_BIND_DN="${LDAP_BIND_DN:-cn=admin,${LDAP_BASE_DN}}" +# The app (inside the container) talks to the local slapd over localhost. +APP_LDAP_URL="${app_ldap__url:-ldap://localhost:389}" + +info() { echo "[INFO] $*"; } +error() { echo "[ERROR] $*" >&2; } + +# ── Locate the OpenLDAP module directory ──────────────────────────────────── +# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The +# path varies by distro; auto-detect rather than hardcode. +MODULE_PATH="" +for p in /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do + if [[ -d "$p" ]]; then MODULE_PATH="$p"; break; fi +done + +# ── TLS certificate for LDAPS / StartTLS ──────────────────────────────────── +# Legacy apps (e.g. the theta42/proxy, Gitea, Emby) bind to LDAP directly over the +# network. To keep password binds off the wire in cleartext we expose LDAPS +# (636) and allow StartTLS on 389. By default a self-signed cert is generated on +# first start; mount your own cert+key at LDAP_CERT_DIR to use a CA-signed cert +# instead (idempotent: existing certs are never overwritten). +LDAP_CERT_DIR="${LDAP_CERT_DIR:-/etc/openldap/certs}" +# CN/SAN hostname clients will verify against. Default to the DNS domain derived +# from the base DN (dc=foo,dc=bar -> foo.bar); override for a public hostname. +LDAP_CERT_CN="${LDAP_CERT_CN:-${LDAP_DOMAIN:-localhost}}" +mkdir -p "$LDAP_CERT_DIR" +if [[ -f "$LDAP_CERT_DIR/ldap.crt" && -f "$LDAP_CERT_DIR/ldap.key" ]]; then + info "Using existing LDAP TLS cert at $LDAP_CERT_DIR (mounted or previously generated)" +else + info "Generating self-signed LDAP TLS cert (CN=$LDAP_CERT_CN, valid 10y)..." + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$LDAP_CERT_DIR/ldap.key" -out "$LDAP_CERT_DIR/ldap.crt" \ + -days 3650 -subj "/CN=$LDAP_CERT_CN" \ + -addext "subjectAltName=DNS:$LDAP_CERT_CN,DNS:localhost,IP:127.0.0.1" \ + >/dev/null 2>&1 || { + error "Failed to generate LDAP TLS cert" + exit 1 + } +fi +# slapd runs as the ldap user and must read both cert and key. +chown -R ldap:ldap "$LDAP_CERT_DIR" 2>/dev/null || true +chmod 600 "$LDAP_CERT_DIR/ldap.key" 2>/dev/null || true +chmod 644 "$LDAP_CERT_DIR/ldap.crt" 2>/dev/null || true + +# ── Generate slapd.conf ───────────────────────────────────────────────────── +# We use slapd.conf (static config) rather than cn=config so the whole directory +# is configured from a single generated file. slapd is started with -f below so +# it actually reads this file (the original entrypoint omitted -f, so slapd +# ignored it entirely). + +cat > /etc/openldap/slapd.conf << SLAPDEOF +include /etc/openldap/schema/core.schema +include /etc/openldap/schema/cosine.schema +include /etc/openldap/schema/inetorgperson.schema +include /etc/openldap/schema/nis.schema + +# Module loading (pw-sha2 provides {SSHA512} used by the app for user passwords; +# ppolicy/memberof/refint are the overlays the app depends on). On OpenLDAP 2.5+ +# the ppolicy schema (pwdPolicy, pwdAccountLockedTime, ...) is built into +# ppolicy.so and registered when the module loads — there is no separate +# ppolicy.schema to include, and loading the module before the overlay below +# is what makes the pwdPolicy objectClass known to slapd. +SLAPMODULEPATH +moduleload back_mdb +moduleload pw-sha2 +moduleload ppolicy +moduleload memberof +moduleload refint + +# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are +# generated/mounted above. We accept clients without their own cert (the common +# case for LDAP bind clients) and treat our own self-signed cert as the CA. +TLSCertificateFile /etc/openldap/certs/ldap.crt +TLSCertificateKeyFile /etc/openldap/certs/ldap.key +TLSCACertificateFile /etc/openldap/certs/ldap.crt +TLSVerifyClient never + +# Database configuration +database mdb +# Store the mdb files on the persistent volume (docker-compose mounts a named +# volume at /var/lib/ldap). Without this, mdb defaults to +# /var/lib/openldap/openldap-data and the directory would be lost on recreation. +directory /var/lib/ldap +suffix SUFFIX_PLACEHOLDER +rootdn BIND_DN_PLACEHOLDER +# rootpw uses {SSHA} (built into slapd, no module needed) so slappasswd never +# depends on a loadable module to generate or verify it. User passwords are +# stored as {SSHA512} by the app itself and verified via the pw-sha2 module. +rootpw ROOTPW_PLACEHOLDER + +# Indexes +index objectClass eq +index uid eq,sub +index mail eq,sub +index cn eq,sub +index member eq +index uidNumber eq +index gidNumber eq + +# ppolicy overlay (account locking — the app's active/inactive toggle relies on +# pwdAccountLockedTime being a known attribute). +overlay ppolicy +ppolicy_default "cn=ppolicy,ou=policies,SUFFIX_PLACEHOLDER" +ppolicy_use_lockout true + +# memberof overlay (reverse group membership) +overlay memberof +memberof-group-oc groupOfNames +memberof-member-ad member +memberof-memberof-ad memberOf + +# refint overlay (referential integrity on group membership) +overlay refint +refint_attributes memberOf member manager owner + +# Access controls +access to attrs=userPassword + by dn="BIND_DN_PLACEHOLDER" write + by anonymous auth + by self write + by * none + +access to * + by dn="BIND_DN_PLACEHOLDER" write + by * read +SLAPDEOF + +# Generate the rootpw hash ({SSHA}, built-in — no module dependency). +HASHED_PASS=$(slappasswd -s "$LDAP_ADMIN_PASS") + +# Fill placeholders. +DC_VALUE="${LDAP_BASE_DN%%,*}" # dc=example +DC_VALUE="${DC_VALUE#dc=}" # example +sed -i "s|SUFFIX_PLACEHOLDER|${LDAP_BASE_DN}|g" /etc/openldap/slapd.conf +sed -i "s|BIND_DN_PLACEHOLDER|${LDAP_BIND_DN}|g" /etc/openldap/slapd.conf +sed -i "s|ROOTPW_PLACEHOLDER|${HASHED_PASS}|g" /etc/openldap/slapd.conf +if [[ -n "$MODULE_PATH" ]]; then + sed -i "s|^SLAPMODULEPATH$|modulepath ${MODULE_PATH}|" /etc/openldap/slapd.conf +else + sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf +fi + +chown ldap:ldap /etc/openldap/slapd.conf 2>/dev/null || true +chown -R ldap:ldap /var/lib/ldap 2>/dev/null || true + +# ── Start slapd ───────────────────────────────────────────────────────────── +info "Starting OpenLDAP (base DN: ${LDAP_BASE_DN})..." +# -f forces slapd to use our generated slapd.conf (not cn=config). +# -h listens on ldap:/// (389: plain + StartTLS) and ldaps:/// (636: LDAPS). +# ldapi:/// is intentionally omitted: its default socket dir doesn't exist on +# Alpine and the container only uses simple bind over ldap://localhost:389. +slapd -d 0 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" & +SLAPD_PID=$! + +# Wait for slapd to answer the root DSE (means it's up, regardless of DB state). +for i in $(seq 1 30); do + if ldapsearch -x -H ldap://localhost:389 -b "" -s base "(objectClass=*)" >/dev/null 2>&1; then + info "OpenLDAP is ready" + break + fi + info "Waiting for OpenLDAP... ($i/30)" + sleep 1 +done + +if ! kill -0 "$SLAPD_PID" 2>/dev/null; then + error "OpenLDAP failed to start" + exit 1 +fi + +# ── Initialize the directory tree (idempotent) ────────────────────────────── +# Only seed if the base DN doesn't exist yet, so restarting the container is safe. +if ! ldapsearch -x -H ldap://localhost:389 -b "$LDAP_BASE_DN" -s base "(objectClass=*)" >/dev/null 2>&1; then + info "Initializing LDAP directory..." + + ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF +dn: ${LDAP_BASE_DN} +objectClass: dcObject +objectClass: organization +dc: ${DC_VALUE} +o: ${ORG_NAME} + +dn: ou=people,${LDAP_BASE_DN} +objectClass: organizationalUnit +ou: people + +dn: ou=groups,${LDAP_BASE_DN} +objectClass: organizationalUnit +ou: groups + +dn: ou=policies,${LDAP_BASE_DN} +objectClass: organizationalUnit +ou: policies + +dn: cn=ppolicy,ou=policies,${LDAP_BASE_DN} +objectClass: top +objectClass: organizationalRole +objectClass: pwdPolicy +cn: ppolicy +pwdAttribute: 2.5.4.35 +pwdLockout: FALSE +pwdMustChange: FALSE +pwdAllowUserChange: TRUE +EOF + + # Required SSO groups. The app gates admin/invite/oauth-admin on these. + for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do + ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true +dn: cn=${group},ou=groups,${LDAP_BASE_DN} +objectClass: groupOfNames +objectClass: top +cn: ${group} +description: ${ORG_NAME} ${group} group +member: ${LDAP_BIND_DN} +EOF + done + + info "LDAP directory initialized" +else + info "LDAP directory already initialized — skipping seed" +fi + +# ── Start Redis ────────────────────────────────────────────────────────────── +# The app stores models/sessions in Redis (model-redis). The all-in-one image +# bundles a Redis server for a self-contained single-node deployment. Point the +# app at an external Redis instead by setting app_redis__host before starting. +# In-memory, no persistence: cache/session data is rebuilt on restart. +if [[ -z "${app_redis__host:-}" ]]; then + info "Starting Redis..." + redis-server --save "" --appendonly no --daemonize no & + REDIS_PID=$! + for i in $(seq 1 15); do + if redis-cli ping >/dev/null 2>&1; then + info "Redis is ready" + break + fi + sleep 0.5 + done + if ! kill -0 "$REDIS_PID" 2>/dev/null; then + error "Redis failed to start" + exit 1 + fi + # App defaults to 127.0.0.1:6379 (the redis client default), so no override + # is needed when running the bundled Redis. +fi + +# ── Generate a JWT secret if none was provided ────────────────────────────── +if [[ -z "${JWT_SECRET:-}" && -z "${app_oauth__jwtSecret:-}" ]]; then + if command -v openssl >/dev/null 2>&1; then + JWT_SECRET=$(openssl rand -hex 32) + else + JWT_SECRET="$LDAP_ADMIN_PASS-jwt-secret-$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' ')" + fi + info "Generated JWT secret (set JWT_SECRET or app_oauth__jwtSecret to persist)" +fi + +# ── Export app_* config overrides for the SSO Manager process ──────────────── +# These are the highest-precedence config layer in @simpleworkjs/conf. Any +# value already set in the environment is preserved (${VAR:-default}). +export app_ldap__url="${app_ldap__url:-$APP_LDAP_URL}" +export app_ldap__bindDN="${app_ldap__bindDN:-$LDAP_BIND_DN}" +export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}" +export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}" +export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}" +export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}" +# OIDC issuer advertised in /.well-known/openid-configuration. Default to the +# public https URL on the SSO subdomain of the LDAP domain; override with +# OAUTH_ISSUER / app_oauth__issuer. Computed here (not in compose) because +# compose v1 doesn't interpolate nested ${VAR:-...} defaults. +export app_oauth__issuer="${app_oauth__issuer:-https://sso.${LDAP_DOMAIN}}" +export app_name="${app_name:-$ORG_NAME}" + +# SMTP (optional). If no user/pass, disable auth by clearing the user. +export app_smtp__host="${app_smtp__host:-${SMTP_HOST:-localhost}}" +export app_smtp__port="${app_smtp__port:-${SMTP_PORT:-587}}" +if [[ -n "${SMTP_USER:-}" || -n "${SMTP_PASS:-}" ]]; then + export app_smtp__user="${app_smtp__user:-$SMTP_USER}" + export app_smtp__pass="${app_smtp__pass:-$SMTP_PASS}" +else + export app_smtp__user="${app_smtp__user:-}" +fi + +# HTTP port for the app (bin/www reads NODE_PORT). +export NODE_PORT="${NODE_PORT:-${PORT:-3001}}" +export NODE_ENV="${NODE_ENV:-production}" + +info "Starting SSO Manager on port ${NODE_PORT}..." +exec "$@" \ No newline at end of file diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..2e83116 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,9 @@ +title: SSO Manager +description: A self-hosted OpenID Connect provider with an OpenLDAP directory and a web management UI +theme: jekyll-theme-cayman +show_downloads: true +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 \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..977068b --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,99 @@ +--- +layout: default +title: Configuration +--- + +# 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_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 new file mode 100644 index 0000000..13959cf --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,177 @@ +--- +layout: default +title: Deployment +--- + +# Deployment Guide + +[← Back to Home](index.html) + +Two supported methods: + +1. **Docker** — a single all-in-one image bundling the app + OpenLDAP + Redis. +2. **Bare metal** — `install.sh` on Debian/Ubuntu (Node.js, OpenLDAP, Redis, systemd unit). + +## Method 1: Docker (all-in-one) + +The image (`Dockerfile.openldap`) bundles OpenLDAP + the app + Redis in one +container. The app connects to the bundled slapd over `localhost:389` +automatically; you only need to set a few secrets. + +```bash +# Minimal: an LDAP admin password + a JWT secret, then build + start. +LDAP_ADMIN_PASS='choose-a-strong-password' \ +JWT_SECRET="$(openssl rand -hex 32)" \ +docker compose up -d --build +``` + +For a customized deployment, put overrides in a `.env` next to +`docker-compose.yml`: + +```env +LDAP_BASE_DN=dc=yourdomain,dc=com +LDAP_DOMAIN=yourdomain.com +LDAP_ADMIN_PASS=your-admin-password +ORG_NAME=Your Org +JWT_SECRET=your-jwt-secret +OAUTH_ISSUER=https://sso.yourdomain.com # browser-facing URL the proxy serves +LDAP_CERT_CN=sso.yourdomain.com # hostname LDAPS clients verify +SMTP_HOST=smtp.yourdomain.com +SMTP_PORT=587 +SMTP_USER=noreply@yourdomain.com +SMTP_PASS=your-smtp-password +SMTP_FROM=Your Org +PORT=3001 +LDAPS_PORT=636 +``` + +Then `docker compose up -d --build`. + +### Access + +- SSO Manager UI: `http://localhost:3001` (HTTP — put a TLS-terminating proxy in front) +- Health: `http://localhost:3001/health` → `{"status":"ok"}` +- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration` +- LDAPS (legacy apps / direct binds): `ldaps://:636` + +### Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `LDAP_BASE_DN` | `dc=example,dc=com` | slapd suffix + app user/group base | +| `LDAP_DOMAIN` | derived from base DN | DNS domain; default for cert CN + 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 (browser-facing URL) | +| `LDAP_CERT_CN` | `LDAP_DOMAIN` | CN/SAN on the LDAPS cert | +| `LDAP_CERT_DIR` | `/etc/openldap/certs` | look for `ldap.crt`+`ldap.key` here (mount your own) | +| `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 | + +Any `app_*` var may also be set directly to override any config value — see +[Configuration](configuration.html). + +### LDAP 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) + StartTLS on `ldap:///` (389). The cert lives on the +`ldap-certs` volume so it persists across container recreation. + +- **Trust it** (clients): copy the cert out and add it to the client's CA store, + or set `TLS_REQCERT never` for quick LAN use: + ```bash + docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt + ``` +- **Use your own cert**: replace the `ldap-certs` named volume with a bind mount + containing your `ldap.crt` + `ldap.key`. The entrypoint leaves existing certs + untouched. + +> Port 389 (plain LDAP) is **not** mapped to the host by default — direct-LDAP +> clients should use LDAPS (636) or StartTLS. + +### Backups (~100 users) + +```bash +docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ + -b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif +``` + +Restorable with `ldapadd`/`ldapmodify` against a fresh instance. Redis is +in-memory (session/cache only — safe to lose). Persist `JWT_SECRET` + +`LDAP_ADMIN_PASS` outside the container (your `.env`, a password manager). + +## Method 2: Bare metal (Debian/Ubuntu) + +`install.sh` is an idempotent installer: Node.js 20.x, OpenLDAP (modules + +overlays + custom schema + directory tree + required groups), the app at +`/opt/sso-manager`, and a systemd unit. + +```bash +sudo ./install.sh \ + -p 'your-ldap-password' \ + -b 'dc=yourdomain,dc=com' \ + -n 'Your Org' \ + -o 3001 +``` + +| Flag | Description | +|------|-------------| +| `-p, --admin-pass` | LDAP admin password (required) | +| `-b, --base-dn` | Base DN (default `dc=example,dc=com`) | +| `-n, --org-name` | Org name (default `SSO Manager`) | +| `-o, --port` | HTTP port (default `3001`) | +| `-j, --jwt-secret` | JWT secret (default auto-generated) | +| `-s, --smtp-config` | SMTP as `host:port:user:pass` | +| `--skip-ldap` | Skip LDAP setup (use existing) | +| `--skip-app` | LDAP setup only | +| `--dry-run` | Show actions without making changes | + +Post-install: + +```bash +sudo systemctl enable --now sso-manager +curl http://localhost:3001/health # -> {"status":"ok"} +``` + +For an existing LDAP server, run `sudo ./install.sh --skip-ldap …` and point the +app at it. To (re)configure overlays on an already-installed slapd, prefer +`ops/ldap-setup.sh` (idempotent, auto-detects the user database). + +## Fronting with a reverse proxy + +The SSO 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 this SSO *and* a +direct LDAP client for user lookups. + +1. **One Docker network** so the proxy reaches the SSO internally at + `http://sso-manager:3001` (token/userinfo, server-to-server) without exposing + the SSO's HTTP port. +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`). +3. **Register the proxy as an OIDC client** in the SSO UI, with `redirectUri` + matching the proxy's callback (`https://proxy.yourdomain.com/api/auth/oidc/callback`). +4. **LDAP for the proxy**: point `ldap.url` at `ldaps://sso-manager:636` and + create a dedicated service account under `ou=people` (e.g. + `cn=ldapclient,ou=people,…`) — don't reuse the admin DN. + +The [`theta42/theta-env`](https://github.com/theta42/theta-env) unified repo +automates all four steps with `./setup.sh` — see +[theta-env docs](https://theta42.github.io/theta-env/). + +## Security notes + +1. **Never commit `secrets.js`** — it's in `.gitignore`. +2. **Use LDAPS / StartTLS** for any LDAP that crosses the network. Port 389 is + not mapped to the host by default so LAN clients can't bind in cleartext. +3. **Persist `JWT_SECRET`** — an auto-generated one invalidates all tokens 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. The all-in-one image runs slapd as the `ldap` user but the app as root + (matches the bare-metal unit). Harden to a non-root user for production. + +[← Back to Home](index.html) \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..f9065b9 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,105 @@ +--- +layout: default +title: 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. + +## Features + +- **OpenID Connect / OAuth 2.0 provider** — issue your own access/refresh/id + tokens; protect your apps with OIDC login. +- **OpenLDAP directory** — users, groups, POSIX accounts (`posixAccount`/ + `inetOrgPerson`), SSH public keys, and sudo roles, with `memberOf` + + referential-integrity overlays. +- **Web management UI** — manage users, groups, and OAuth clients from a + browser; invite/password-reset flows over email. +- **LDAPS for legacy apps** — apps that bind LDAP directly (Gitea, Emby, …) + can use LDAPS (636) / StartTLS. +- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or + run each piece separately via `app_*` env config. + +## Quick Start + +### Docker (all-in-one) + +```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 +``` + +The web UI comes up at `http://localhost:3001`. See the +[Deployment Guide](deployment.html) for the full set of `app_*` env vars. + +### Bare metal (Debian/Ubuntu) + +```bash +sudo ./install.sh +``` + +Idempotent installer — installs Node.js, OpenLDAP, Redis, configures the app, +and starts a systemd unit. Re-run to update. + +### Run it together with the proxy + +The proxy ([theta42/proxy](https://github.com/theta42/proxy)) fronts this SSO +under TLS and protects it with OIDC, while also binding LDAP directly. Run both +with one command via [theta-env](https://github.com/theta42/theta-env): + +```bash +git clone --recursive https://github.com/theta42/theta-env.git +cd theta-env && cp .env.example .env # edit, then: +./setup.sh +``` + +## Documentation + +- [Deployment Guide](deployment.html) — Docker + bare metal, the config layers, + the `app_*` env reference, backups. +- [Configuration](configuration.html) — every `app_*` env var and the conf + merge order. +- [OAuth / OIDC](oauth.html) — the provider: discovery, client management, + token lifetimes, scopes. +- [LDAP](ldap.html) — directory layout, TLS, overlays, schema, direct-bind + service accounts. + +## Architecture + +``` +┌─────────────┐ +│ Browser / │ +│ OIDC apps │ +└──────┬──────┘ + │ HTTP/HTTPS + ▼ +┌────────────────────────┐ ┌─────────────┐ +│ Express SSO Manager │◄────►│ Redis │ +│ - OIDC provider │ │ - sessions │ +│ - web UI (:3001) │ │ - models │ +│ - management API │ └─────────────┘ +└────────┬───────────────┘ + │ ldapi/ldap (localhost) + ▼ +┌────────────────────────┐ +│ OpenLDAP (slapd) │ +│ - users / groups │ +│ - LDAPS :636 │─── legacy apps bind directly +│ - StartTLS :389 │ +└────────────────────────┘ +``` + +## Community + +- [GitHub Repository](https://github.com/theta42/sso-manager-node) +- [Issue Tracker](https://github.com/theta42/sso-manager-node/issues) +- [Pull Requests](https://github.com/theta42/sso-manager-node/pulls) + +## License + +MIT License — see the repository for details. \ No newline at end of file diff --git a/docs/ldap.md b/docs/ldap.md new file mode 100644 index 0000000..2c4caaa --- /dev/null +++ b/docs/ldap.md @@ -0,0 +1,153 @@ +--- +layout: default +title: LDAP +--- + +# LDAP Directory + +[← Back to Home](index.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 legacy apps that bind LDAP +directly — 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`). + +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. **Admin permission checks read the group's `member` list**, not +`memberOf` on the user. + +The SSO requires three groups (seeded automatically by the entrypoint / +`install.sh`): + +| Group | Grants | +|-------|--------| +| `app_sso_admin` | full admin (users, groups, settings) | +| `app_sso_oauth_admin` | OAuth client management | +| `app_sso_invite` | invitation management | + +## 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). + +## Direct-bind service accounts + +For apps that bind LDAP directly, create a dedicated **service account** under +`ou=people` (e.g. `cn=ldapclient,ou=people,`) with a strong password — +**don't reuse the admin DN**. The theta-env bootstrap creates this account +automatically (`cn=ldapclient`) and the proxy binds as it. + +Example bind test: + +```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 +``` + +## 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`. +- **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`. + +`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 + +```bash +docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \ + -b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif +``` + +Restorable with `ldapadd`/`ldapmodify` against a fresh instance. + +## 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 new file mode 100644 index 0000000..d57795a --- /dev/null +++ b/docs/oauth.md @@ -0,0 +1,105 @@ +--- +layout: default +title: OAuth / OIDC +--- + +# OAuth 2.0 / OpenID Connect + +[← Back to Home](index.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 (must match exactly). +- `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 from the web UI (as a member of the `app_sso_oauth_admin` +group) or the HTTP API at `/api/oauth/client` (auth via the `auth-token` header +from a login): + +| Method | Path | Action | +|--------|------|--------| +| `GET` | `/api/oauth/client` | list clients | +| `POST` | `/api/oauth/client` | create a client (returns the raw `client_secret` once) | +| `GET` | `/api/oauth/client/:id` | get one | +| `PUT` | `/api/oauth/client/:id` | update redirect URIs / scopes / groups | +| `DELETE` | `/api/oauth/client/:id` | delete | +| `POST` | `/api/oauth/client/:id/rotate` | rotate the secret (returns the new raw secret once) | + +> All client-management endpoints are gated by the `app_sso_oauth_admin` group. + +## 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/install.sh b/install.sh new file mode 100755 index 0000000..5a7d1d0 --- /dev/null +++ b/install.sh @@ -0,0 +1,719 @@ +#!/usr/bin/env bash +# install.sh - Idempotent standalone installer for Theta42 SSO Manager +# For Debian/Ubuntu systems +# +# This script: +# 1. Installs Node.js 20.x +# 2. Installs and configures OpenLDAP with required schemas/overlays +# 3. Deploys the SSO Manager application +# 4. Sets up systemd services +# +# Usage: +# sudo ./install.sh [OPTIONS] +# +# Options: +# -p, --admin-pass PASSWORD LDAP admin password (required, or set via LDAP_ADMIN_PASS env) +# -b, --base-dn DN Base DN (default: dc=example,dc=com) +# -n, --org-name NAME Organization name shown in UI/email (default: SSO Manager) +# -o, --port PORT HTTP port for SSO Manager (default: 3001) +# -j, --jwt-secret SECRET JWT secret for OAuth (default: auto-generated) +# -s, --smtp-config CONFIG SMTP config as host:port:user:pass +# --skip-ldap Skip LDAP installation (use existing LDAP) +# --skip-app Skip application installation (LDAP setup only) +# --dry-run Show what would be done without making changes +# -h, --help Show this help +# +# Environment variables (alternative to flags): +# LDAP_ADMIN_PASS, LDAP_BASE_DN, PORT, JWT_SECRET, SMTP_* + +set -euo pipefail + +# ── Defaults ────────────────────────────────────────────────────────────────── +BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}" +ADMIN_PASS="${LDAP_ADMIN_PASS:-}" +ORG_NAME="${ORG_NAME:-SSO Manager}" +PORT="${PORT:-3001}" +JWT_SECRET="${JWT_SECRET:-}" +SMTP_HOST="${SMTP_HOST:-}" +SMTP_PORT="${SMTP_PORT:-587}" +SMTP_USER="${SMTP_USER:-}" +SMTP_PASS="${SMTP_PASS:-}" +SKIP_LDAP="${SKIP_LDAP:-false}" +SKIP_APP="${SKIP_APP:-false}" +DRY_RUN="${DRY_RUN:-false}" + +INSTALL_DIR="/opt/sso-manager" +SYSTEMD_DIR="/etc/systemd/system" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# ── Helper functions ────────────────────────────────────────────────────────── +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } +dry_run() { if [[ "$DRY_RUN" == "true" ]]; then echo "[DRY-RUN] $*"; fi; } + +usage() { + grep '^#' "$0" | sed 's/^# \{0,1\}//' + exit 0 +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + -p|--admin-pass) + ADMIN_PASS="$2" + shift 2 + ;; + -b|--base-dn) + BASE_DN="$2" + shift 2 + ;; + -n|--org-name) + ORG_NAME="$2" + shift 2 + ;; + -o|--port) + PORT="$2" + shift 2 + ;; + -j|--jwt-secret) + JWT_SECRET="$2" + shift 2 + ;; + -s|--smtp-config) + IFS=':' read -r SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS <<< "$2" + shift 2 + ;; + --skip-ldap) + SKIP_LDAP="true" + shift + ;; + --skip-app) + SKIP_APP="true" + shift + ;; + --dry-run) + DRY_RUN="true" + shift + ;; + -h|--help) + usage + ;; + *) + error "Unknown option: $1" + usage + ;; + esac +done + +# Validate required parameters +if [[ -z "$ADMIN_PASS" ]]; then + error "LDAP admin password is required (-p or LDAP_ADMIN_PASS env)" + exit 1 +fi + +# Generate JWT secret if not provided +if [[ -z "$JWT_SECRET" ]]; then + JWT_SECRET=$(openssl rand -hex 32) + info "Generated JWT secret: ${JWT_SECRET:0:8}..." +fi + +# Derive the DNS domain from the base DN (dc=foo,dc=bar -> foo.bar) for email +# sender defaults. Override with LDAP_DOMAIN if set. +if [[ -z "${LDAP_DOMAIN:-}" ]]; then + LDAP_DOMAIN=$(echo "$BASE_DN" | sed 's/^dc=//; s/,dc=/./g') +fi + +# ── System checks ───────────────────────────────────────────────────────────── +check_root() { + if [[ $EUID -ne 0 ]]; then + error "This script must be run as root (sudo)" + exit 1 + fi +} + +check_os() { + if [[ ! -f /etc/debian_version ]]; then + error "This script is for Debian/Ubuntu systems only" + exit 1 + fi + info "Detected $(cat /etc/os-release | grep PRETTY_NAME | cut -d'"' -f2)" +} + +# ── Package installation ────────────────────────────────────────────────────── +install_package() { + local pkg="$1" + if dpkg -l | grep -q "^ii $pkg "; then + info "Package $pkg is already installed" + return 0 + fi + dry_run "Would install package: $pkg" + [[ "$DRY_RUN" == "true" ]] && return 0 + apt-get update -qq + apt-get install -y -qq "$pkg" + info "Installed $pkg" +} + +install_nodejs() { + if command -v node &>/dev/null && node --version | grep -q "v20"; then + info "Node.js 20.x is already installed" + return 0 + fi + dry_run "Would install Node.js 20.x" + [[ "$DRY_RUN" == "true" ]] && return 0 + + info "Installing Node.js 20.x..." + # Use NodeSource repository for Node.js 20.x + apt-get update -qq + apt-get install -y -qq curl gnupg ca-certificates + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1 + apt-get install -y -qq nodejs + info "Installed Node.js $(node --version)" +} + +# ── OpenLDAP installation and configuration ─────────────────────────────────── +install_openldap() { + if command -v slapd &>/dev/null; then + info "OpenLDAP is already installed" + return 0 + fi + dry_run "Would install OpenLDAP" + [[ "$DRY_RUN" == "true" ]] && return 0 + + info "Installing OpenLDAP..." + + # Pre-seed debconf for non-interactive installation + debconf-set-selections << EOF +slapd slapd/internal/adminpw string $ADMIN_PASS +slapd slapd/password1 string $ADMIN_PASS +slapd slapd/password2 string $ADMIN_PASS +slapd slapd/domain string ${BASE_DN#dc=} +slapd slapd/backend string MDB +slapd shared/organization string $ORG_NAME +slapd slapd/purge_database boolean true +slapd slapd/move_old_database boolean true +slapd slapd/invalid_config boolean true +EOF + + apt-get update -qq + apt-get install -y -qq slapd ldap-utils + + # Configure ldap.conf + cat > /etc/ldap/ldap.conf << LDAPCONF +BASE $BASE_DN +URI ldap://localhost +LDAPCONF + + # Set proper permissions + chmod 644 /etc/ldap/ldap.conf + + info "OpenLDAP installed" +} + +configure_openldap() { + info "Configuring OpenLDAP..." + dry_run "Would configure OpenLDAP with base DN: $BASE_DN" + [[ "$DRY_RUN" == "true" ]] && return 0 + + # Wait for slapd to be ready + for i in {1..10}; do + if ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=*)" dn >/dev/null 2>&1; then + info "OpenLDAP is ready" + break + fi + sleep 1 + done + + # Detect the database DN for our suffix + DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \ + "(&(objectClass=olcDatabaseConfig)(olcSuffix=${BASE_DN}))" dn 2>/dev/null \ + | grep "^dn:" | head -1 | sed 's/^dn: //') + + if [[ -z "$DB_DN" ]]; then + # Try to find any database and update its suffix + DB_DN=$(ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" \ + "(objectClass=olcDatabaseConfig)" dn 2>/dev/null \ + | grep "^dn:" | head -1 | sed 's/^dn: //') + + if [[ -n "$DB_DN" ]]; then + info "Updating database suffix to $BASE_DN" + ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF +dn: $DB_DN +changetype: modify +replace: olcSuffix +olcSuffix: $BASE_DN +EOF + fi + fi + + if [[ -z "$DB_DN" ]]; then + error "Could not detect OpenLDAP database configuration" + return 1 + fi + + info "Using database: $DB_DN" + + # 1. Load pw-sha2 module + if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "pw-sha2"; then + info "Loading pw-sha2 module..." + ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF +dn: cn=module{0},cn=config +changetype: modify +add: olcModuleLoad +olcModuleLoad: pw-sha2 +EOF + else + info "pw-sha2 module already loaded" + fi + + # 2. Load ppolicy module + if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "ppolicy"; then + info "Loading ppolicy module..." + ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF +dn: cn=module{0},cn=config +changetype: modify +add: olcModuleLoad +olcModuleLoad: ppolicy +EOF + else + info "ppolicy module already loaded" + fi + + # 3. Load memberof module + if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "memberof"; then + info "Loading memberof module..." + ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF +dn: cn=module{1},cn=config +changetype: modify +add: olcModuleLoad +olcModuleLoad: memberof +EOF + else + info "memberof module already loaded" + fi + + # 4. Load refint module + if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=config" "(objectClass=olcModuleList)" olcModuleLoad 2>/dev/null | grep -q "refint"; then + info "Loading refint module..." + ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF +dn: cn=module{1},cn=config +changetype: modify +add: olcModuleLoad +olcModuleLoad: refint +EOF + else + info "refint module already loaded" + fi + + # 5. Add ppolicy overlay + if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*ppolicy*)" dn 2>/dev/null | grep -qi "ppolicy"; then + info "Adding ppolicy overlay..." + ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF +dn: olcOverlay=ppolicy,$DB_DN +objectClass: olcOverlayConfig +objectClass: olcPPolicyConfig +olcOverlay: ppolicy +olcPPolicyDefault: cn=ppolicy,ou=policies,$BASE_DN +olcPPolicyUseLockout: TRUE +EOF + else + info "ppolicy overlay already configured" + fi + + # 6. Add memberof overlay + if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*memberof*)" dn 2>/dev/null | grep -qi "memberof"; then + info "Adding memberof overlay..." + ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF +dn: olcOverlay=memberof,$DB_DN +objectClass: olcConfig +objectClass: olcMemberOf +objectClass: olcOverlayConfig +objectClass: top +olcOverlay: memberof +olcMemberOfDangling: ignore +olcMemberOfRefInt: TRUE +olcMemberOfGroupOC: groupOfNames +olcMemberOfMemberAD: member +olcMemberOfMemberOfAD: memberOf +EOF + else + info "memberof overlay already configured" + fi + + # 7. Add refint overlay + if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "$DB_DN" "(olcOverlay=*refint*)" dn 2>/dev/null | grep -qi "refint"; then + info "Adding refint overlay..." + ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF +dn: olcOverlay=refint,$DB_DN +objectClass: olcConfig +objectClass: olcOverlayConfig +objectClass: olcRefintConfig +objectClass: top +olcOverlay: refint +olcRefintAttribute: memberof member manager owner +EOF + else + info "refint overlay already configured" + fi + + # 8. Add database indexes + info "Configuring database indexes..." + for index in "mail eq,sub" "uid eq,sub" "cn eq,sub" "member eq" "uidNumber eq" "gidNumber eq"; do + attr=$(echo "$index" | cut -d' ' -f1) + types=$(echo "$index" | cut -d' ' -f2) + ldapmodify -Q -Y EXTERNAL -H ldapi:/// << EOF || true +dn: $DB_DN +changetype: modify +add: olcDbIndex +olcDbIndex: $attr $types +EOF + done + + # 9. Load custom theta42 schema + if ! ldapsearch -Q -Y EXTERNAL -H ldapi:/// -b "cn=schema,cn=config" "(olcObjectClasses=*theta42Person*)" olcObjectClasses 2>/dev/null | grep -q "theta42"; then + info "Loading custom theta42 schema..." + ldapadd -Q -Y EXTERNAL -H ldapi:/// << EOF +dn: cn=theta42,cn=schema,cn=config +objectClass: olcSchemaConfig +cn: theta42 +olcAttributeTypes: ( 1.3.6.1.4.1.99999.1.1 + NAME 'dateOfBirth' + DESC 'Date of birth in ISO 8601 format YYYY-MM-DD' + EQUALITY caseExactMatch + SUBSTR caseExactSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + SINGLE-VALUE ) +olcObjectClasses: ( 1.3.6.1.4.1.99999.2.1 + NAME 'theta42Person' + DESC 'Theta42 SSO extended person attributes' + AUXILIARY + MAY ( dateOfBirth ) ) +EOF + else + info "theta42 schema already loaded" + fi + + # 10. Create base directory structure + BIND_DN="cn=admin,$BASE_DN" + + # Create base DN if it doesn't exist + if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$BASE_DN" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then + info "Creating base DN structure..." + DC_VALUE="${BASE_DN#dc=}" + DC_VALUE="${DC_VALUE%%,*}" + + ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF +dn: $BASE_DN +objectClass: dcObject +objectClass: organization +dc: $DC_VALUE +o: $ORG_NAME +EOF + else + info "Base DN already exists" + fi + + # Create OUs + for ou in people groups policies; do + dn="ou=$ou,$BASE_DN" + if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then + info "Creating $ou OU..." + ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF +dn: ou=$ou,$BASE_DN +objectClass: organizationalUnit +ou: $ou +EOF + else + info "OU $ou already exists" + fi + done + + # 11. Create default ppolicy + if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "cn=ppolicy,ou=policies,$BASE_DN" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then + info "Creating default ppolicy..." + ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF +dn: cn=ppolicy,ou=policies,$BASE_DN +objectClass: top +objectClass: organizationalRole +objectClass: pwdPolicy +cn: ppolicy +pwdAttribute: 2.5.4.35 +pwdLockout: FALSE +pwdMustChange: FALSE +pwdAllowUserChange: TRUE +EOF + else + info "Default ppolicy already exists" + fi + + # 12. Create required SSO groups + for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do + dn="cn=$group,ou=groups,$BASE_DN" + if ! ldapsearch -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// -b "$dn" -s base "(objectClass=*)" dn 2>/dev/null | grep -q "dn:"; then + info "Creating group: $group" + ldapadd -x -D "$BIND_DN" -w "$ADMIN_PASS" -H ldapi:/// << EOF +dn: $dn +objectClass: groupOfNames +objectClass: top +cn: $group +description: $ORG_NAME $group group +member: $BIND_DN +EOF + else + info "Group $group already exists" + fi + done + + info "OpenLDAP configuration complete" +} + +# ── Application installation ────────────────────────────────────────────────── +install_app() { + info "Installing SSO Manager application..." + dry_run "Would install application to $INSTALL_DIR" + [[ "$DRY_RUN" == "true" ]] && return 0 + + # Create installation directory + mkdir -p "$INSTALL_DIR" + + # Copy application files + info "Copying application files..." + cp -r "$SCRIPT_DIR/nodejs/"* "$INSTALL_DIR/" + + # Install npm dependencies + info "Installing npm dependencies..." + cd "$INSTALL_DIR" + npm ci --only=production --quiet + + # Create secrets configuration + info "Creating application configuration..." + cat > "$INSTALL_DIR/conf/secrets.js" << SECRETEOF +'use strict'; + +module.exports = { + port: $PORT, + ldap: { + url: 'ldap://localhost', + bindDN: 'cn=admin,$BASE_DN', + bindPassword: '$ADMIN_PASS', + userBase: 'ou=people,$BASE_DN', + groupBase: 'ou=groups,$BASE_DN', + }, + smtp: { + host: '${SMTP_HOST:-localhost}', + port: ${SMTP_PORT:-587}, + user: '${SMTP_USER:-}', + pass: '${SMTP_PASS:-}', + from: '${ORG_NAME} ', + }, + voipms: { + username: '${VOIPMS_USER:-}', + password: '${VOIPMS_PASS:-}', + did: '${VOIPMS_DID:-}', + }, + oauth: { + issuer: '', + jwtSecret: '$JWT_SECRET', + token_lifetime: { + access_token: 3600, + refresh_token: 2592000 + } + }, +}; +SECRETEOF + + # Create base configuration + cat > "$INSTALL_DIR/conf/base.js" << BASEEOF +'use strict'; + +module.exports = { + name: "$ORG_NAME", + userModel: 'ldap', + redis: { + prefix: 'sso_manager_' + }, + ldap: { + url: 'ldap://localhost', + bindDN: 'cn=admin,$BASE_DN', + bindPassword: '__IN SECRETS FILE__', + userBase: 'ou=people,$BASE_DN', + groupBase: 'ou=groups,$BASE_DN', + userFilter: '(objectClass=posixAccount)', + userNameAttribute: 'uid' + }, + oauth: { + issuer: '', + jwtSecret: '__in secrets file__', + token_lifetime: { + access_token: 3600, + refresh_token: 2592000 + } + }, + smtp: { + host: 'localhost', + port: 587, + secure: false, + from: '$ORG_NAME ', + }, +}; +BASEEOF + + # Set ownership + chown -R root:root "$INSTALL_DIR" + chmod -R 755 "$INSTALL_DIR" + + info "Application installed to $INSTALL_DIR" +} + +# ── Systemd service configuration ───────────────────────────────────────────── +install_systemd() { + info "Installing systemd service..." + dry_run "Would install systemd service" + [[ "$DRY_RUN" == "true" ]] && return 0 + + cat > "$SYSTEMD_DIR/sso-manager.service" << UNITEOF +[Unit] +Description=Theta42 SSO Manager +Documentation=file://$INSTALL_DIR/README.md +After=network.target slapd.service +Wants=slapd.service + +[Service] +Type=simple +User=root +WorkingDirectory=$INSTALL_DIR +ExecStart=/usr/bin/node $INSTALL_DIR/bin/www +Restart=on-failure +RestartSec=5 +Environment=NODE_ENV=production +Environment=NODE_PORT=$PORT + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +UNITEOF + + systemctl daemon-reload + systemctl enable sso-manager.service + + info "Systemd service installed" +} + +# ── Verification ────────────────────────────────────────────────────────────── +verify_installation() { + info "Verifying installation..." + + local errors=0 + + # Check OpenLDAP + if command -v slapd &>/dev/null; then + if systemctl is-active --quiet slapd; then + info "✓ OpenLDAP is running" + else + warn "✗ OpenLDAP is not running" + ((errors++)) + fi + else + warn "✗ OpenLDAP is not installed" + ((errors++)) + fi + + # Check application + if [[ -d "$INSTALL_DIR" ]]; then + info "✓ Application is installed" + else + warn "✗ Application is not installed" + ((errors++)) + fi + + # Check systemd service + if systemctl is-enabled --quiet sso-manager.service 2>/dev/null; then + info "✓ Systemd service is enabled" + else + warn "✗ Systemd service is not enabled" + ((errors++)) + fi + + if [[ $errors -eq 0 ]]; then + info "Installation verified successfully" + else + warn "Installation completed with $errors issue(s)" + fi + + return $errors +} + +# ── Main execution ──────────────────────────────────────────────────────────── +main() { + echo + echo "==============================================" + echo " Theta42 SSO Manager Installer" + echo "==============================================" + echo + echo "Configuration:" + echo " Base DN: $BASE_DN" + echo " Port: $PORT" + echo " Install dir: $INSTALL_DIR" + echo " Skip LDAP: $SKIP_LDAP" + echo " Skip App: $SKIP_APP" + echo + + check_root + check_os + + if [[ "$SKIP_LDAP" != "true" ]]; then + echo + info "=== Installing OpenLDAP ===" + install_openldap + configure_openldap + fi + + if [[ "$SKIP_APP" != "true" ]]; then + echo + info "=== Installing SSO Manager ===" + install_nodejs + install_app + install_systemd + fi + + echo + verify_installation + + echo + echo "==============================================" + echo " Installation Complete!" + echo "==============================================" + echo + + if [[ "$SKIP_APP" != "true" ]]; then + info "Start the service with: systemctl start sso-manager" + info "View logs with: journalctl -fu sso-manager" + info "Access the UI at: http://localhost:$PORT" + fi + + if [[ "$SKIP_LDAP" != "true" ]]; then + echo + info "LDAP Configuration:" + info " Base DN: $BASE_DN" + info " Bind DN: cn=admin,$BASE_DN" + info " Admin pass: (set by you)" + echo + info "Required SSO groups created:" + info " - app_sso_admin" + info " - app_sso_invite" + info " - app_sso_oauth_admin" + fi + + echo +} + +main diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js index e380edd..7b2ae4f 100644 --- a/nodejs/conf/base.js +++ b/nodejs/conf/base.js @@ -1,22 +1,29 @@ 'use strict'; +// Base configuration — generic defaults usable by anyone. +// +// These are NON-secret defaults. Per-deployment values (LDAP bind DN, user/group +// bases, SMTP host/user, OAuth issuer, sender address) should be overridden via +// conf/secrets.js or `app_*` environment variables (see @simpleworkjs/conf). +// Secret values (passwords, JWT secret, API keys) MUST come from secrets.js or +// `app_*` env vars — never commit them here. module.exports = { - name: "Theta42 SSO", + name: "SSO Manager", // displayed in the UI and outbound email userModel: 'ldap', // pam, redis, ldap redis: { prefix: 'sso_manager_' }, ldap: { - url: 'ldaps://ldap.internal.theta42.com:636', - bindDN: 'cn=admin,dc=theta42,dc=com', - bindPassword: '__IN SRECREST FILE__', - userBase: 'ou=people,dc=theta42,dc=com', - groupBase: 'ou=groups,dc=theta42,dc=com', + url: 'ldap://localhost', + bindDN: 'cn=admin,dc=example,dc=com', + bindPassword: '__in secrets file__', + userBase: 'ou=people,dc=example,dc=com', + groupBase: 'ou=groups,dc=example,dc=com', userFilter: '(objectClass=posixAccount)', userNameAttribute: 'uid' }, oauth: { - issuer: 'https://sso.theta42.com', + issuer: '', // falls back to the request host at runtime (routes/index.js) jwtSecret: '__in secrets file__', token_lifetime: { access_token: 3600, // 1 hour (seconds) @@ -29,11 +36,11 @@ module.exports = { did: '__in secrets file__', }, smtp: { - host: 'mail.wgnode.com', + host: 'localhost', port: 587, secure: false, - user: 'noreply@users.theta42.com', + user: 'noreply@example.com', pass: '__in secrets file__', - from: 'Theta42 Accounts ', + from: 'SSO Manager ', }, -}; +}; \ No newline at end of file diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 6f79649..26dc23c 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", "@popperjs/core": "^2.11.8", - "@simpleworkjs/conf": "^1.0.0", + "@simpleworkjs/conf": "^1.1.0", "bcrypt": "^6.0.0", "bootstrap": "^5.3.8", "ejs": "^3.1.10", @@ -1126,9 +1126,9 @@ } }, "node_modules/@simpleworkjs/conf": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.0.0.tgz", - "integrity": "sha512-p1dQAELW0oUBRpDoz260TYw18IMI/Y11xYAb17P1MEPjsTAUB0LWE/6ZeA2VQmpU/LXoRnDysg0G/oASGILyUA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.1.0.tgz", + "integrity": "sha512-MKRQQ4JAH2tbEm87NdkmfikTT58Tyk/SFbvCC7zKja0bK6j8zYyBXTQUJ0rnvFOVEalDWd/au4AEiptOCEqgvA==", "license": "MIT", "dependencies": { "extend": "^3.0.2" diff --git a/nodejs/package.json b/nodejs/package.json index 930105e..8c3fcd5 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -23,7 +23,7 @@ "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", "@popperjs/core": "^2.11.8", - "@simpleworkjs/conf": "^1.0.0", + "@simpleworkjs/conf": "^1.1.0", "bcrypt": "^6.0.0", "bootstrap": "^5.3.8", "ejs": "^3.1.10", diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 25ea2bc..59b9cf1 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -39,6 +39,11 @@ frontEndModules.forEach(dep => { // local folder. router.use('/static', express.static(path.join(__dirname, '../public'))) +// Public health endpoint for container/orchestration healthchecks. +// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated. +router.get('/health', function(req, res) { + res.json({ status: 'ok' }); +}); router.get('/tos', function(req, res) { res.render('tos', {...values, tosHtml}); diff --git a/ops/ldif/add_index.ldif b/ops/ldif/add_index.ldif new file mode 100644 index 0000000..f29c74f --- /dev/null +++ b/ops/ldif/add_index.ldif @@ -0,0 +1,3 @@ +dn: olcDatabase={1}mdb,cn=config +add: olcDbIndex +olcDbIndex: mail eq,sub diff --git a/ops/ldif/add_nodes.ldif b/ops/ldif/add_nodes.ldif new file mode 100644 index 0000000..0e5cbfc --- /dev/null +++ b/ops/ldif/add_nodes.ldif @@ -0,0 +1,7 @@ +dn: ou=people,dc=theta42,dc=com +objectClass: organizationalUnit +ou: People + +dn: ou=groups,dc=theta42,dc=com +objectClass: organizationalUnit +ou: Groups diff --git a/ops/ldif/logging.ldif b/ops/ldif/logging.ldif new file mode 100644 index 0000000..5494c10 --- /dev/null +++ b/ops/ldif/logging.ldif @@ -0,0 +1,4 @@ +dn: cn=config +changetype: modify +replace: olcLogLevel +olcLogLevel: stats diff --git a/ops/ldif/memberOfmodule.ldif b/ops/ldif/memberOfmodule.ldif new file mode 100644 index 0000000..c47d420 --- /dev/null +++ b/ops/ldif/memberOfmodule.ldif @@ -0,0 +1,4 @@ +dn: cn=module{0},cn=config +changetype: modify +add: olcModuleLoad +olcModuleLoad: memberof.la diff --git a/ops/ldif/memberof_config.ldif b/ops/ldif/memberof_config.ldif new file mode 100644 index 0000000..3129540 --- /dev/null +++ b/ops/ldif/memberof_config.ldif @@ -0,0 +1,17 @@ +dn: cn=module,cn=config +cn: module +objectClass: olcModuleList +olcModuleLoad: memberof +olcModulePath: /usr/lib/ldap + +dn: olcOverlay={0}memberof,olcDatabase={1}mdb,cn=config +objectClass: olcConfig +objectClass: olcMemberOf +objectClass: olcOverlayConfig +objectClass: top +olcOverlay: memberof +olcMemberOfDangling: ignore +olcMemberOfRefInt: TRUE +olcMemberOfGroupOC: groupOfNames +olcMemberOfMemberAD: member +olcMemberOfMemberOfAD: memberOf diff --git a/ops/ldif/refinit1.ldif b/ops/ldif/refinit1.ldif new file mode 100644 index 0000000..e5a9f64 --- /dev/null +++ b/ops/ldif/refinit1.ldif @@ -0,0 +1,3 @@ +dn: cn=module{1},cn=config +add: olcmoduleload +olcmoduleload: refint diff --git a/ops/ldif/refint2.ldif b/ops/ldif/refint2.ldif new file mode 100644 index 0000000..fb6db88 --- /dev/null +++ b/ops/ldif/refint2.ldif @@ -0,0 +1,7 @@ +dn: olcOverlay={1}refint,olcDatabase={1}mdb,cn=config +objectClass: olcConfig +objectClass: olcOverlayConfig +objectClass: olcRefintConfig +objectClass: top +olcOverlay: {1}refint +olcRefintAttribute: memberof member manager owner diff --git a/ops/ldif/stuff.ldif b/ops/ldif/stuff.ldif new file mode 100644 index 0000000..020de7b --- /dev/null +++ b/ops/ldif/stuff.ldif @@ -0,0 +1,26 @@ +# Entry 1: cn=stuff-manager,ou=groups,dc=theta42,dc=com +dn: cn=stuff-manager,ou=groups,dc=theta42,dc=com +cn: stuff-manager +gidnumber: 1498 +objectclass: posixGroup +objectclass: top + +# Entry 1: cn=stuff-manager,ou=people,dc=theta42,dc=com +dn: cn=stuff-manager,ou=people,dc=theta42,dc=com +cn: stuff-manager +gidnumber: 1498 +givenname: Stuff +homedirectory: /home/stuff-manager +loginshell: /bin/bash +objectclass: inetOrgPerson +objectclass: sudoRole +objectclass: ldapPublicKey +objectclass: posixAccount +objectclass: top +sn: Manager +sudocommand: ALL +sudohost: ALL +sudouser: stuff-manager +uid: stuff-manager +uidnumber: 1498 +userpassword: {MD5}YSv5lro/uQ0B7i81kGDPVQ== diff --git a/ops/ldif/tls.ldif b/ops/ldif/tls.ldif new file mode 100644 index 0000000..122766f --- /dev/null +++ b/ops/ldif/tls.ldif @@ -0,0 +1,7 @@ +dn: cn=config +changetype: modify +replace: olcTLSCertificateFile +olcTLSCertificateFile: /etc/ldap/sasl2/ldap-server.crt +- +replace: olcTLSCertificateKeyFile +olcTLSCertificateKeyFile: /etc/ldap/sasl2/ldap-server.key diff --git a/secrets.js.example b/secrets.js.example new file mode 100644 index 0000000..606ead9 --- /dev/null +++ b/secrets.js.example @@ -0,0 +1,42 @@ +'use strict'; + +// Example secrets configuration file (file-based config, for non-Docker use). +// Copy to nodejs/conf/secrets.js and fill in your values. +// +// Docker users: you usually don't need this file — pass `app_*` env vars instead +// (see DEPLOYMENT.md). This file is for bare-metal / mounted-config deployments. +// Values here override conf/base.js and win over .js. `app_*` env +// vars (if any are set) override this file too. + +module.exports = { + port: 3001, + name: 'SSO Manager', // shown in UI and outbound email + ldap: { + url: 'ldap://localhost', // or ldaps://host:636 for TLS + bindDN: 'cn=admin,dc=example,dc=com', + bindPassword: 'your-ldap-password', + userBase: 'ou=people,dc=example,dc=com', + groupBase: 'ou=groups,dc=example,dc=com', + }, + smtp: { + host: 'smtp.example.com', + port: 587, + secure: false, // true for 465, false for other ports + user: 'noreply@example.com', + pass: 'your-smtp-password', + from: 'SSO Manager ', + }, + voipms: { + username: '', // VoIP.ms username (optional) + password: '', // VoIP.ms password (optional) + did: '', // VoIP.ms DID (optional) + }, + oauth: { + issuer: '', // falls back to the request host at runtime + jwtSecret: 'generate-a-secure-random-string-here', + token_lifetime: { + access_token: 3600, // 1 hour in seconds + refresh_token: 2592000 // 30 days in seconds + } + }, +}; \ No newline at end of file