LDAP Directory

← Back to Home

Looking for a plainer explanation of accounts, groups, and managers instead of schema/attribute detail? See Accounts, Groups & Managers.

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=<uid>,ou=people,<base> 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=<name>,ou=groups,<base> (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 <slug>_admin group, and each <slug>_admin into its <slug>_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=<uid>,ou=groups,<base>, 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 <uid>’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:

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:

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.comldaps://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:

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.com192.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:

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 managers it actually needs.

Example bind test (a service account with a password set):

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, applied to your domain:

Field the app asks for Value
Host / URL ldaps://<your-sso-host>:636 (preferred), or ldap://<host>:389 + StartTLS
Bind DN a dedicated service account — e.g. cn=ldapclient,ou=people,<base> (see above)
Bind password that service account’s password
User search base ou=people,<base>
User search filter (objectClass=posixAccount)
Username attribute uid
Email attribute mail
Group search base ou=groups,<base>
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):

environment:
  LDAP_URL: ldaps://sso.example.com:636
  LDAP_BIND_DN: cn=ldapclient,ou=people,dc=yourdomain,dc=com
  LDAP_BIND_PASSWORD: <service-account-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: 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,<base>
    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 slapcatslapadd 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,<base>.
  • 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 <admin-password> configures all of the above idempotently against a running slapd (auto-detects the database holding your base DN, and verifies pwdAccountLockedTime is live — the attribute the app’s active/inactive toggle depends on).

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):

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:

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-<date>.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:

sudo ./ops/ldap-setup.sh -p 'admin-password' -b dc=yourdomain,dc=com

LDAP connection refused

docker compose exec sso-manager sh -c 'ldapsearch -x -H ldap://localhost:389 -b "" -s base'
systemctl status slapd   # bare metal

← Back to Home