think
16px
820px

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>:389 plus StartTLS is acceptable. Avoid plaintext ldap://
    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's mail, 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 mail attribute 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 with sAMAccountName (the Windows username) instead of email, tell us —
    the default filter already accepts both, and LDAP_ATTR_EMAIL can 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 the ldaps:// / 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's mail and 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. In only mode, 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.