Skip to content

LDAP Developer Guide

Ed Mozley edited this page Jul 16, 2026 · 6 revisions

LDAP β€” Developer Guide

How LDAP sign-in is built, why it's built that way, and the traps that will bite you if you change it. For the admin-facing guide see LDAP & Active Directory.

Shipped for issue #47.


Where it lives

File Role
includes/ldap.php Everything directory-related: connect, bind, search, groups, the access gate, analyst provisioning
login.php The analyst login form β€” decides how to check a password (local / directory / refuse)
api/system/save_sso_provider.php Writes a provider; branches on protocol
api/system/get_sso_providers.php Lists providers; admin-gated, never returns secrets
api/system/test_ldap_connection.php The admin Test button β€” returns the real error, deliberately
system/sso/index.php System β†’ Authentication: the Type dropdown, LDAP fields, presets
system/help/sso.php The help page (registry entry: sso)

It reuses the SSO plumbing, deliberately

LDAP is not a parallel auth system. It's a second protocol value in the existing auth_providers table, beside 'oidc':

auth_providers.protocol = 'oidc' | 'ldap'

That means it inherits, for free: the provider admin UI, the auto_create_users JIT flag, default_modules, tenant_id scoping, the analyst_sso_identities link table, session handling, and the strict per-provider isolation rules. One auth story, not two.

Column groups are mutually exclusive. OIDC rows use issuer_url / client_id / client_secret / scopes; LDAP rows use the ldap_* columns. Saving one protocol blanks the other group, so switching a provider's type can't leave stale settings quietly in force.

⚠️ issuer_url and client_id are NOT NULL, and LDAP rows store '' in them. This looks like a wart; it isn't optional. db_verify only ever ADDS missing columns (db_verify.php:2614) β€” it never MODIFYs an existing one. Relaxing those columns to NULL would apply on fresh installs and silently not apply on upgraded ones, so LDAP rows would insert fine on one and fatal on the other. Empty string works identically on both.

Schema

All nullable, all added to both database/freeitsm.sql and the $schema array in api/system/db_verify.php:

ldap_host, ldap_port, ldap_encryption            -- none | starttls | ldaps
ldap_bind_dn, ldap_bind_password                 -- service account; password encrypted at rest
ldap_base_dn, ldap_user_filter                   -- %s = what the user typed
ldap_attr_username, ldap_attr_email,
ldap_attr_name, ldap_attr_guid                   -- attribute mapping
ldap_group_base_dn, ldap_group_filter,           -- %s = the user's DN
ldap_analyst_group, ldap_user_group              -- the access gate

ldap_bind_password goes through encryptValue() / decryptValue() (AES-256-GCM), same as OIDC's client_secret. ldapGetProvider() decrypts on read. Neither ever leaves the server: the list endpoint exposes only has_secret / has_bind_password booleans.


The authentication flow

ldapAuthenticate($provider, $login, $password) β€” the two-step bind:

guard: empty password?  -> reject BEFORE touching the network
ldapOpen()              -> connect, protocol v3, referrals OFF, timeouts, STARTTLS
ldapBindService()       -> bind as the read-only service account
ldapFindUser()          -> search; exactly one match or it's an error
ldap_bind($userDn, $pw) -> THE password check
ldapBindService()       -> re-bind as service to read groups
ldapUserGroups()        -> the groups they're in

Returns ['ok' => bool, 'reason' => 'credentials'|'config'|'notfound', 'user' => [...]].

reason exists so callers can choose what to reveal: the login form says "invalid username or password" for everything, while the admin Test button shows the detail. Don't leak config errors to the login form.


The traps

1. Empty password = unauthenticated bind = log in as anyone

The single most dangerous thing here. RFC 4513 defines a bind with a DN but an empty password as an unauthenticated bind, and directories are permitted to answer it with success. A naive implementation:

if (ldap_bind($ds, $userDn, $password)) { /* logged in! */ }

...lets anyone in as anyone by leaving the password box blank.

// includes/ldap.php β€” never remove this, never rely on the server
if ($password === '') {
    return ['ok' => false, 'reason' => 'credentials', 'error' => 'A password is required.'];
}

Both test rigs REJECT an empty bind, so no amount of local testing proves this guard exists. Delete it and every test still passes. It is covered by an explicit unit-style assertion that the function short-circuits before any network call.

login.php also rejects empty input independently β€” two layers, on purpose.

2. "No such object" means "you can't read that", not "it isn't there"

OpenLDAP's default ACL ends by * none. A service account without an explicit read grant sees an empty directory and searches return No such object β€” not a permission error. This sends people hunting for a wrong base DN when the real problem is ACLs. ldapFindUser() deliberately says so in its error text.

Grant read to the service account, but not on userPassword β€” it never needs to see hashes.

3. memberOf shows DIRECT groups only

On AD, a user in IT-Support, which is itself a member of All-Staff, has only IT-Support in memberOf. Gate on All-Staff using memberOf and they're wrongly denied.

So groups are resolved by asking the groups who their members are, not by reading memberOf off the user:

AD:       (&(objectClass=group)(member:1.2.840.113556.1.4.1941:=%s))   <- LDAP_MATCHING_RULE_IN_CHAIN
OpenLDAP: (&(objectClass=groupOfNames)(member=%s))

1.2.840.113556.1.4.1941 is AD-only and walks the nesting. OpenLDAP has no equivalent, and no memberOf at all without the memberof overlay loaded β€” hence the different preset.

4. AD returns referrals

Subtree searches come back with ref: entries pointing at other naming contexts. Chasing them makes searches fail or hang. LDAP_OPT_REFERRALS, 0 is mandatory, not tuning.

5. objectGUID is binary; entryUUID is text

The identity link (analyst_sso_identities.subject) must be an immutable id β€” a DN changes on rename or OU move and would orphan the account. AD's objectGUID is raw binary, OpenLDAP's entryUUID is a readable string. ldapStringifyId() hex-encodes anything that isn't clean printable UTF-8, so both end up storable and comparable:

AD:        025812afe3a1324290a9b8b528280eb1      (hex-encoded binary)
OpenLDAP:  2ed219c2-1581-1041-9ec0-edc15902e9de  (as-is)

Also: ldap_get_entries() lowercases attribute names. Read them via ldapAttr(), which lowercases the key β€” don't index $entry['objectGUID'].

6. Filter injection

%s substitution happens after ldap_escape($v, '', LDAP_ESCAPE_FILTER). Substituting first would let * or )(|(uid=* rewrite the filter. Use str_replace, not sprintf β€” a filter may legitimately contain several %s.

7. Password expiry must not apply

A directory-backed analyst has an unusable random local hash. Letting local password expiry catch them sends them to force_password_change.php to change a password that doesn't exist. login.php sets $skipPasswordExpiry when a directory did the authenticating.


The access gate

ldapAccessRole($provider, $ldapUser) returns 'analyst' | 'user' | null.

both group fields blank  -> 'analyst'   (gate off; single-team default)
in ldap_analyst_group    -> 'analyst'   (wins if in both β€” the more capable role)
in ldap_user_group       -> 'user'
otherwise                -> null        -> refused

It fails closed. If the group read throws, the group list is empty, and empty never matches a configured gate. ldapAuthenticate() swallows group-read errors precisely so the gate β€” not an exception path β€” makes the decision. Never "helpfully" default to granting on error.

This gate is what makes JIT safe: without it, auto_create_users turns every employee in the directory into an analyst. It runs before provisioning, so a denied user leaves no trace in the database.


The Docker test rigs

Two directories, because building against one silently produces code shaped like that one. Both live in the repo at docker/ldap-test/ β€” compose file, seed scripts and a README with every credential:

docker compose -f docker/ldap-test/docker-compose.yml up -d
bash docker/ldap-test/seed-ad.sh && bash docker/ldap-test/seed-ad-company.sh
openldap:   osixia/openldap:1.5.0        # port 3890  dc=freeitsm,dc=test
samba-ad:   nowsci/samba-domain:latest   # port 3891  DC=ad,DC=freeitsm,DC=test
                                         # privileged: true, INSECURELDAP=true

Samba AD DC is the important one β€” it gives you real AD semantics (sAMAccountName, memberOf, nested groups, referrals, binary objectGUID, disabled-account refusal) without a Windows Server. INSECURELDAP=true relaxes ldap server require strong auth so simple binds work over plain 389 in testing.

The AD rig is seeded with a realistic company ("Northwind Trading") rather than flat users, because the awkward cases only exist in a realistic tree:

  • people in nested OUs (OU=IT,OU=Staff,OU=Northwind)
  • an account with no email β†’ JIT must refuse cleanly
  • a disabled leaver β†’ AD refuses the bind even with the right password
  • Siobhan O'Connor, JΓΌrgen MΓΌller, LucΓ­a GarcΓ­a β†’ apostrophes and UTF-8
  • NW-All-Staff containing the department groups, not people β†’ nested-group gating

Run against both rigs before shipping a change. If a change passes on OpenLDAP but you didn't try AD, you have tested nothing about nested groups, sAMAccountName or referrals.


Known gaps / next

  • Self-service portal. ldap_user_group resolves to 'user', but api/self-service/login.php does not yet do an LDAP bind β€” so a user-group member is correctly refused analyst access but cannot yet sign in to the portal via LDAP either. That's the next slice.
  • Per-group permission sets (gdoring's "read-only, and so on" in #47) β€” today groups pick which kind of account you get, not a role. Mapping a group onto an RBAC role is the natural follow-on; see Roles & Permissions.
  • Directory sync β€” deliberately out of scope. JIT-on-login only; nobody is created before they first sign in, and nobody is deactivated in FreeITSM when the directory disables them (they simply can't sign in). Full sync is a much bigger feature; see the non-goals in docs/design/sso-multi-tenant.md.
  • ldapCreateAnalyst() mirrors oidcCreateAnalyst() in api/auth/oidc_callback.php rather than sharing with it. Folding both into one provisioning helper is worthwhile β€” but it's a refactor of working auth code and deserves its own change.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally