LDAP Directory
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’suserFilteris(objectClass=posixAccount), so a user is “a real account” iff it hasposixAccount.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.com → ldaps://sso.example.com:636). That is convenient,
but it implies LDAP clients reach your directory through the same public
hostname — which usually means port-forwarding 636 through your router.
Do not port-forward LDAPS (636) to the public internet. LDAP simple binds
have no rate limiting and are a brute-force target. Instead, use one of these
internal-only patterns and set conf.ldap.ldapsHost (or
app_ldap__ldapsHost) so the /integrations page shows the right URL.
1. Same Docker / local network host (best for apps on this machine)
If the LDAP client runs on the same Docker network as the SSO Manager (for
example, the bundled theta-env stack), use the internal service name:
ldaps://sso-manager:636
In conf/secrets.js:
ldap: {
ldapsHost: 'sso-manager',
ldapsPort: 636,
}
The proxy in theta-env already uses this internally. The bundled slapd cert
includes sso-manager in its SAN when LDAP_CERT_CN is left at its default,
so hostname verification works without extra setup.
2. LAN host behind your router (best for separate home-lan machines)
Create an internal-only DNS record — e.g. ldap.internal.example.com →
192.168.1.10 — using your router, Pi-hole, or a local hosts file. Then get
or generate a cert whose SAN/CN matches that internal name:
- Let’s Encrypt wildcard (
*.internal.example.com) works if you own the public domain and can complete DNS-01 challenge; the record itself can stay private/routable only inside your LAN. - Internal CA is fine for a pure LAN: run a small CA, issue a cert for
ldap.internal.example.com, and distribute the CA cert to clients. - Self-signed with
LDAP_CERT_CN=ldap.internal.example.comalso works; copy the generatedldap.crtto 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
mailis set unless you give it one (it never needs a mailbox). - Leaving the password blank is fine — no
userPasswordattribute is set at all, and an entry with nouserPasswordsimply 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 — leaveldap.nestedGroupsServerSideatfalse. With it, set that totrueand configure:overlay nestgroup nestgroup-base ou=groups,<base> nestgroup-flags member-filter memberof-filter memberof-valuesFlags are space-separated; the comma form the man page’s
{a, b, c}notation suggests is rejected.member-valuesis deliberately omitted — it expands thememberattribute 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 fromGET /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 aslapcat→slapaddreload, not a restart. - Custom schema: the
theta42Personauxiliary objectClass withdateOfBirth— seeops/ldap-setup.shfor the LDIF. - Directory tree:
ou=people,ou=groups,ou=policiesunder the base DN, a defaultpwdPolicyatcn=ppolicy,ou=policies,<base>. - Required groups:
app_sso_admin,app_sso_invite,app_sso_oauth_admin, andapp_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