LDAP / Active Directory Authentication — Operator Guide
Obscura can authenticate your users against your existing Active Directory or LDAP
directory. When enabled, people sign in on the normal Obscura login form with their directory
username (email) and directory password. There is no separate SSO button and no browser redirect
— the login form is unchanged. On first successful directory login a user is created automatically
(just-in-time provisioning, provider='ldap', no local password stored in Obscura), and the
Obscura roles that correspond to their directory groups are granted.
Directory login is disabled by default. Nothing changes for an existing deployment until you set
LDAP_ENABLED=true and the required LDAP_* variables. This document is what to hand your AD/LDAP
administrator, the exact configuration to set, and how to diagnose the common failure modes.
1. What Obscura needs from your AD / LDAP administrator
Give this checklist to whoever runs your directory. Obscura connects outbound to the directory
as a read-only client; it never writes to your directory and never stores directory passwords.
- [ ] A directory URL. Preferably
ldaps://<host>:636(LDAP-over-TLS). If only plain LDAP is
available,ldap://<host>:389plus StartTLS is acceptable. Avoid plaintextldap://
without TLS in production — the service-account and user passwords cross the wire on bind. - [ ] A read-only service account for the search bind: its full distinguished name (DN)
and password. This account only needs permission to search the directory and read
the user'smail, display-name, and group-membership attributes. It does not need write
access. Example DN:CN=obscura-svc,OU=Service Accounts,DC=corp,DC=example,DC=id. - [ ] The base DN to search under — the subtree that contains your user accounts, e.g.
DC=corp,DC=example,DC=id. A narrower OU (e.g.OU=Staff,DC=corp,DC=example,DC=id) also
works and reduces the search scope. - [ ] Confirmation that the
mailattribute is populated for the users who will log in — this
is the login identifier Obscura matches on and the email it provisions the account with. If
your users log in withsAMAccountName(the Windows username) instead of email, tell us —
the default filter already accepts both, andLDAP_ATTR_EMAILcan point at a different
attribute if the login email lives elsewhere. - [ ] The group DNs you want mapped to Obscura roles. For each Obscura role (e.g. admin,
staff), which AD group's members should receive it. Example:
CN=Obscura Admins,OU=Groups,DC=corp,DC=example,DC=id→ Obscura role admin. You configure
these mappings later in the Obscura UI (Admin → Directory (LDAP)); you just need the group
DNs to hand over. - [ ] If the directory uses a private/internal CA for its TLS certificate: the CA
certificate in PEM format, so Obscura can verify theldaps:/// StartTLS connection.
2. Configuration (environment variables)
Set these on the Obscura backend (via your --env-file / secret mechanism — do not commit the
bind password). The values below are Active Directory defaults and work unchanged against a
standard AD forest; the only value you always change is the connection/identity block at the top.
LDAP_ENABLED=true
LDAP_URL=ldaps://dc1.corp.example.id:636
LDAP_BIND_DN=CN=obscura-svc,OU=Service Accounts,DC=corp,DC=example,DC=id
LDAP_BIND_PASSWORD=... # via --env-file / secret, not committed
LDAP_BASE_DN=DC=corp,DC=example,DC=id
LDAP_USER_FILTER=(&(objectClass=user)(|(mail=%s)(sAMAccountName=%s)))
LDAP_ATTR_EMAIL=mail
LDAP_ATTR_NAME=displayName
LDAP_ATTR_GROUPS=memberOf
LDAP_MODE=mixed # or "only" to disable local passwords (admins keep break-glass)
# LDAP_CA_FILE=/run/secrets/ldap-ca.pem # for a private CA
# LDAP_START_TLS=true # only with an ldap:// URL (not ldaps://)
# LDAP_INSECURE_SKIP_VERIFY=true # TESTING ONLY
Reference for every variable Obscura reads:
| Variable | Default | Meaning |
|---|---|---|
LDAP_ENABLED |
false |
Master switch. When unset/false, directory login is off and the login path is byte-for-byte today's local-password behavior. |
LDAP_URL |
(required when enabled) | ldaps://host:636 (TLS) or ldap://host:389 (plain / StartTLS). |
LDAP_START_TLS |
false |
Upgrade a plain ldap:// connection to TLS after connect. Mutually exclusive with an ldaps:// URL (choosing both is a boot error). |
LDAP_CA_FILE |
(none) | Path to a PEM file with the private CA that signed the directory's TLS cert. Needed only for a private/internal CA. |
LDAP_INSECURE_SKIP_VERIFY |
false |
Skip TLS certificate verification. Testing only — logged loudly at boot. Never use in production. |
LDAP_BIND_DN |
(none) | The read-only service-account DN used for the search bind. |
LDAP_BIND_PASSWORD |
(none) | That account's password. Supply via secret / env file. |
LDAP_BASE_DN |
(required when enabled) | Subtree to search for users, e.g. DC=corp,DC=example,DC=id. |
LDAP_USER_FILTER |
(&(objectClass=user)(\|(mail=%s)(sAMAccountName=%s))) |
LDAP filter template; each %s is replaced with the (escaped) login the user typed. The AD default matches on email or Windows username. |
LDAP_ATTR_EMAIL |
mail |
Attribute holding the user's email (used as the Obscura account email). |
LDAP_ATTR_NAME |
displayName |
Attribute holding the user's display name. |
LDAP_ATTR_GROUPS |
memberOf |
Attribute holding the user's group memberships (DN strings), matched against your group→role mappings. |
LDAP_MODE |
mixed |
mixed = try the local password first, then the directory. only = directory passwords only; local passwords are refused except for break-glass admins (see below). |
Fail-closed: if LDAP_ENABLED=true but LDAP_URL or LDAP_BASE_DN is missing, or you set
both LDAP_START_TLS=true and an ldaps:// URL, or LDAP_MODE is not mixed/only, the server
refuses to boot with a clear message rather than silently degrading — the same posture as every
other provider.
3. How login works
- One login form, no redirect. Users type their email and password as always. Obscura decides
the backend from the account, transparently: - A known local user (someone with an Obscura password) is verified locally. A wrong local
password does not fall through to the directory. - An unknown user, or a user previously provisioned from the directory (
provider='ldap'),
is authenticated against the directory: Obscura binds with the service account, searches for
exactly one matching entry, then re-binds as that user with the submitted password. - Just-in-time provisioning. On the first successful directory bind, Obscura creates the account
(provider='ldap', no local password hash) using the directory'smailand display name. - Group → role mapping. After each successful directory login, Obscura reconciles the user's
directory groups (memberOf) against the mappings you configure in Admin → Directory (LDAP):
roles for newly-matched groups are granted, and directory-granted roles whose group the user
has left are revoked. Roles you assigned manually in Obscura are never touched. LDAP_MODE=only+ break-glass. Inonlymode, local passwords are refused for everyone —
authentication must go through the directory — except users who hold the Obscura admin
role, who keep their local password as a break-glass path if the directory is unreachable. This
prevents a directory outage from locking every administrator out.- Passwords live in the directory. A directory user cannot change their password inside Obscura
(the change-password form is hidden for them, and the API rejects it): they change it in
AD/LDAP. Generic "invalid email or password" is returned for every directory failure mode
(server down, no match, wrong password, disabled account) so the login form never reveals whether
an account exists.
4. The OpenLDAP test rig (--profile ldap)
For end-to-end testing without a real AD, the compose stack ships an opt-in OpenLDAP server seeded
with two users and two groups. It is not part of the default stack and the demo obscura
service keeps LDAP disabled.
Start it:
docker compose -f deploy/docker-compose.yml --profile ldap up -d ldap
What it seeds (domain dc=obscura,dc=local, admin password admin):
| Login (email) | Password | Group | Group DN | |
|---|---|---|---|---|
| User 1 | ldapuser1@obscura.local |
ldappass1 |
obscura-admins | cn=obscura-admins,ou=groups,dc=obscura,dc=local |
| User 2 | ldapuser2@obscura.local |
ldappass2 |
obscura-staff | cn=obscura-staff,ou=groups,dc=obscura,dc=local |
Seed users are inetOrgPerson entries under ou=people,dc=obscura,dc=local; groups are
groupOfNames under ou=groups,dc=obscura,dc=local. The memberof + refint overlays are loaded
so each user's memberOf reflects their group automatically.
Important: OpenLDAP is not Active Directory. OpenLDAP has no objectClass=user and no
sAMAccountName (those are AD-only), and users are keyed by uid, not sAMAccountName. So when
you point Obscura at this rig, override the user filter to an inetOrgPerson/uid shape. An
Obscura instance pointed at the rig over the compose network uses roughly:
LDAP_ENABLED=true
LDAP_URL=ldap://ldap:389
LDAP_BIND_DN=cn=admin,dc=obscura,dc=local
LDAP_BIND_PASSWORD=admin
LDAP_BASE_DN=dc=obscura,dc=local
LDAP_USER_FILTER=(&(objectClass=inetOrgPerson)(|(mail=%s)(uid=%s)))
LDAP_ATTR_EMAIL=mail
LDAP_ATTR_NAME=displayName
LDAP_ATTR_GROUPS=memberOf
LDAP_MODE=mixed
(ldap://ldap:389 is the in-compose-network address; from the host the rig is also published on
389/636.) Map cn=obscura-admins,ou=groups,dc=obscura,dc=local → the admin role in Admin →
Directory (LDAP), log in as ldapuser1@obscura.local / ldappass1, and confirm the account is
provisioned with the admin role.
memberOf fallback (honest caveat). If, after seeding, a user's memberOf comes back empty
(some osixia/openldap image builds do not apply the cn=config overlay LDIF cleanly), drop
01-memberof-overlay.ldif from the rig's mounts and instead drive groups directly: seed each user
with an explicit group-DN-list attribute and set LDAP_ATTR_GROUPS to that attribute. The
authenticator's attribute → groups → role path is identical, so nothing else changes. Real AD
populates memberOf natively, so the default LDAP_ATTR_GROUPS=memberOf is correct against AD
regardless.
5. Troubleshooting
Because all login failures return the same generic "invalid email or password" to the user, the
server log is where you diagnose directory problems. Each directory failure logs a WARN with
a distinct prefix:
| Symptom | Likely cause | Fix |
|---|---|---|
Boot fails: LDAP_URL is required / LDAP_BASE_DN is required / LDAP_START_TLS is mutually exclusive / invalid LDAP_MODE |
Fail-closed config validation | Correct the offending LDAP_* env var and restart. |
Every LDAP login → invalid credentials, log WARN ... dial / ... starttls |
Can't reach the server, or the TLS handshake failed | Check LDAP_URL host/port and firewall; for a private CA set LDAP_CA_FILE; confirm you are using StartTLS with ldap:// or ldaps://, not both. |
Log WARN ... service bind |
The service-account search bind was rejected | Fix LDAP_BIND_DN / LDAP_BIND_PASSWORD (wrong DN form or wrong password). |
Log WARN ... expected exactly 1 result, got 0 (or got 2) |
The search matched no user (or more than one) | Check LDAP_BASE_DN scope and LDAP_USER_FILTER; confirm the login value is present in mail/sAMAccountName (or your chosen attribute); ensure the login isn't ambiguous across sub-trees. |
Log WARN ... user bind |
The right user was found, but the submitted password was wrong | Normal for a mistyped password. If a correct password fails, confirm the account isn't locked/expired/disabled in the directory. |
| Login succeeds but the user gets no Obscura roles | No memberOf returned, or no group→role mapping configured |
Verify LDAP_ATTR_GROUPS returns the user's group DNs (on the OpenLDAP rig, confirm the memberof overlay applied — see the fallback in §4); add the group→role mapping in Admin → Directory (LDAP). |
You can watch the relevant lines with, e.g.:
docker compose -f deploy/docker-compose.yml logs -f obscura | grep -i ldap
For a quick directory-side sanity check independent of Obscura, an ldapsearch from any client
proves the URL, bind credentials, base DN, and filter in isolation:
ldapsearch -H ldaps://dc1.corp.example.id:636 \
-D "CN=obscura-svc,OU=Service Accounts,DC=corp,DC=example,DC=id" -w '<bind-password>' \
-b "DC=corp,DC=example,DC=id" "(mail=someone@corp.example.id)" mail displayName memberOf
If that returns exactly one entry with a populated mail and memberOf, Obscura will authenticate
that user and map their groups.