LDAP
pykrete ships a small, read-only LDAP server for applications that can't speak OpenID Connect and only offer
"log in with LDAP" or "sync users from LDAP". Each application (OAuth client) gets its own directory subtree
o=<client_id>, sees only the users and groups it has been granted, and users authenticate with a per-app
app password instead of their main credentials.
If the application supports OpenID Connect, prefer OpenID Connect: LDAP can't do passkeys, consent or app encryption keys.
Transport |
LDAPS only (TLS from the first byte) on an address you choose. No plaintext LDAP, no StartTLS. |
Operations |
Simple bind, search, WhoAmI. Everything that writes is refused. |
App account |
|
User account |
|
Visibility |
users and groups of the roles the app was granted with |
Enabling the server
The LDAP listener is off unless the config file (the JSON file passed with --config, see
Configuration) has an ldap object:
{
"ldap": {
"listen": "0.0.0.0:636",
"base_dn": "dc=example,dc=com",
"certificate": "/var/lib/acme/auth.infra.delta.rocks/fullchain.pem",
"private_key": "/var/lib/acme/auth.infra.delta.rocks/key.pem"
}
}
| Key | Meaning |
|---|---|
|
Socket address |
|
Suffix of every DN, for example |
|
PEM certificate chain served to LDAP clients (leaf first). |
|
PEM private key for that certificate. |
Notes:
-
The certificate and key are read once at startup. After renewing the certificate, restart the backend.
-
If the
ldapobject is present but the files can't be read,base_dnis invalid orlistencan't be bound, the backend refuses to start. -
TLS 1.2 and 1.3 are offered; client certificates are not requested.
-
The listener is separate from the HTTP server, so the HTTP reverse proxy is not involved. If you put a TCP load balancer in front, use TLS passthrough: pykrete terminates TLS itself and doesn't understand the PROXY protocol.
-
Clients that insist on StartTLS (
ldap://+-ZZ, "StartTLS" in app settings) won't work. Configure them forldaps:/// "SSL/TLS".
Directory layout
dc=example,dc=com <base_dn>
`-- o=<client_id> one subtree per app; an app only ever sees its own
|-- cn=service bind-only DN of the app itself (no entry)
|-- ou=people
| `-- uid=<username> users holding at least one visible role
`-- ou=groups
`-- cn=<role_id> visible roles
An app's view of the root (<base_dn> with subtree scope) contains only <base_dn> itself and its own
o=<client_id> subtree. Client ids look like C followed by 32 letters and digits and are matched
case-sensitively in DNs. The container names people, groups and service and the base_dn suffix are matched
case-insensitively.
Users
| Attribute | Value | Usable in filters |
|---|---|---|
|
|
yes |
|
current username |
yes |
|
profile name, or the username if no name is set |
yes |
|
same as |
yes |
|
every verified email address of the user (multi-valued, may be absent) |
yes |
|
DN of every visible group the user is in |
equality with a full group DN, presence |
|
pykrete user id ( |
equality, presence |
Use deltaUserId as the unique/immutable id in application settings: uid and the entry DN change when the
user renames themselves. There is no entryUUID, sn, givenName or userPassword.
Groups
Groups are pykrete roles (see Scopes and claims).
| Attribute | Value | Usable in filters |
|---|---|---|
|
|
yes |
|
role id |
yes |
|
role name |
yes |
|
role description, omitted when empty |
yes |
|
DN of every user holding the role |
equality with a full user DN, presence |
Only roles granted directly to a user count. Roles a user holds only through an organization membership don't appear in LDAP.
Containers and root DSE
-
<base_dn>:objectClass: topplus its first RDN as an attribute (for exampledc: example). -
o=<client_id>:objectClass: top, organization,o: <client_id>. -
ou=people,ou=groups:objectClass: top, organizationalUnit,ou. -
Root DSE (base
"", scope base, readable without binding):namingContexts(the base DN),supportedLDAPVersion: 3,supportedExtension: 1.3.6.1.4.1.4203.1.11.3(WhoAmI),supportedControl: 1.2.840.113556.1.4.319(paged results),vendorName: delta.rocks.
Setting up an app
-
Create a confidential client with a client secret for the app (see Self-service or Administration). Its client id becomes
o=<client_id>, its secret is the service bind password. The client doesn't need any redirect URLs or grant types for LDAP. -
Pick the roles the app may see. Create roles and grant them to users if needed. With the admin GraphQL API (GraphiQL is described in Administration):
mutation { openid { createRole(userRoleId: "staff", name: "Staff", description: "Employees") { userRoleId } } userdata { user(userId: "U...") { grantRole(userRoleId: "staff") { userRoleId } } } } -
Make the roles visible to the app.
setClientRolesreplaces the app's list of visible roles (unknown role ids are ignored) and returns the resulting list:mutation { ldap { setClientRoles(clientId: "C...", userRoleIds: ["staff"]) { userRoleId name } } }Check the current list with
query { ldap { clientRoles(clientId: "C...") { userRoleId name } } }. -
Issue app passwords for the users who will log in through the app (next section).
How visibility works: for each role, setClientRoles creates a scope ldap:search:<role_id> on the identity
provider's own audience (named "LDAP search: <role name>") and adds it to the client's allowed scopes. The LDAP
server treats every ldap:search:* allowed scope of the client as a visible role. The app sees:
-
users who hold at least one visible role,
-
the visible roles as groups,
-
for each user, only the
memberOfvalues of visible roles.
After changing the client's allowed scopes, check clientRoles and run setClientRoles again if roles are
missing.
App passwords
An app password is a random secret for one user in one app. Binding with the main password would mean handing it to every LDAP application, and accounts that sign in with passkeys only have no password at all. App passwords are per-app, revocable, and useless for pykrete's own sign-in.
Format |
32 random letters and digits, shown once when issued |
Storage |
SHA-256 of the password; the plaintext is not kept |
Scope |
one |
Requirement |
the user must hold at least one role visible to the client, otherwise the bind fails |
Issue, list and revoke them with the admin GraphQL API:
mutation {
ldap {
issueAppPassword(userId: "U...", clientId: "C...", name: "laptop")
}
}
query { ldap { appPasswords(userId: "U...") { clientId name createdAt } } }
mutation { ldap { revokeAppPassword(userId: "U...", clientId: "C...", name: "laptop") } }
issueAppPassword returns the password as a string. Hand it to the user over a secure channel.
Users see their app passwords (name, app and creation date) on the App passwords page of the
account dashboard (/account/app-passwords).
Binding
| Identity | Bind DN | Password |
|---|---|---|
Anonymous |
empty |
empty |
App (service) |
|
client secret of that client |
User |
|
an app password issued for that user and client |
-
Only simple binds are supported. SASL binds get
authMethodNotSupported. -
A non-empty DN with an empty password is refused with
unwillingToPerform(no "unauthenticated" binds). -
Wrong DN or password gives
invalidCredentials. The service bind requires the client to use a client secret. -
The
uidin a bind DN is matched exactly, including case. Returned entries carry the current username. -
A failed bind leaves the connection anonymous.
What each identity can search:
Anonymous |
Root DSE only. Any other search gets |
App (service) |
Everything in its own |
User |
The containers and its own user entry only (including its |
Search bases outside the app's own subtree (another app's o=..., or cn=service) return noSuchObject.
Examples
The examples use these shell variables; replace the client id with yours.
CLIENT_ID=Cxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
BASE="o=$CLIENT_ID,dc=example,dc=com"
# -y reads the whole file as the password, so write it without a trailing newline:
printf '%s' 'client-secret-here' > client-secret.txt
If the certificate is not from a public CA, point the OpenLDAP tools at your CA with
LDAPTLS_CACERT=/path/to/ca.pem.
Read the root DSE anonymously:
ldapsearch -H ldaps://auth.infra.delta.rocks -x -b "" -s base '(objectClass=*)'
List all users the app can see:
ldapsearch -H ldaps://auth.infra.delta.rocks -x \
-D "cn=service,$BASE" -y client-secret.txt \
-b "ou=people,$BASE" -s one '(objectClass=inetOrgPerson)' \
uid displayName mail memberOf deltaUserId
dn: uid=alice,ou=people,o=Cxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx,dc=example,dc=com
uid: alice
displayName: Alice Example
mail: alice@example.com
memberOf: cn=staff,ou=groups,o=Cxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx,dc=example,dc=com
deltaUserId: Uxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Find one user by login name, the usual "search, then bind as the user" first step:
ldapsearch -H ldaps://auth.infra.delta.rocks -x -D "cn=service,$BASE" -y client-secret.txt \
-b "ou=people,$BASE" '(&(objectClass=person)(uid=alice))' 1.1
Users in a group, and members of a group:
ldapsearch -H ldaps://auth.infra.delta.rocks -x -D "cn=service,$BASE" -y client-secret.txt \
-b "ou=people,$BASE" "(memberOf=cn=staff,ou=groups,$BASE)" uid
ldapsearch -H ldaps://auth.infra.delta.rocks -x -D "cn=service,$BASE" -y client-secret.txt \
-b "cn=staff,ou=groups,$BASE" -s base '(objectClass=*)' member
Page through large result sets (simple paged results control):
ldapsearch -H ldaps://auth.infra.delta.rocks -x -D "cn=service,$BASE" -y client-secret.txt \
-E pr=200/noprompt -b "ou=people,$BASE" '(objectClass=person)' uid
Check a user's app password:
ldapwhoami -H ldaps://auth.infra.delta.rocks -x \
-D "uid=alice,ou=people,$BASE" -y app-password.txt
# dn:uid=alice,ou=people,o=Cxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx,dc=example,dc=com
Typical application settings
Most applications ask for the same handful of values:
| Setting | Value |
|---|---|
Server / port |
|
Encryption |
LDAPS / "SSL/TLS" (implicit TLS), not StartTLS |
Bind DN / manager DN |
|
Bind password |
the client secret |
User base DN |
|
User filter |
|
Login / username attribute |
|
Unique id attribute |
|
Email / display name |
|
Group base DN |
|
Group filter / object class |
|
Group membership |
|
Users then log into the application with their username and an app password issued for that application.
Supported operations
| Operation | Behaviour |
|---|---|
Bind |
simple only, see Binding |
Search |
scopes base, one, subtree and children; |
Extended: WhoAmI ( |
returns |
Other extended operations (StartTLS, password modify, ...) |
|
Add, Modify, Delete, ModifyDN, Compare |
|
Abandon |
ignored |
Unbind |
closes the connection |
Controls |
simple paged results ( |
Search details:
-
Attribute selection: an empty list,
*or+returns all attributes;1.1returns none. Attribute names are case-insensitive and the aliasesuserid,commonName,rfc822mailbox,organizationName,organizationalUnitNameanddomainComponentare understood. -
Filters: and, or, not, equality, substring and presence. Approximate match (
~=) behaves like equality. Matching is case-insensitive. -
Ordering (
>=,<=) and extensible-match filters, and filters on attributes the server doesn't know, evaluate to Undefined, which stays Undefined under!, so they never match. A presence test on an unknown attribute is plain false. -
memberOfandmemberfilters need the full DN of a group or user in the same app subtree. -
At most 1000 entries per search, including when paging; a smaller client size limit is honoured. Larger results are cut off with
sizeLimitExceeded. -
Connections: 10 s for the TLS handshake, closed after 5 minutes without a request, requests up to 128 KiB.
Security notes
-
An app's service account can read the username, display name, verified emails and visible roles of every user holding a visible role. Grant only the roles the app needs.
-
App passwords are bound to one app: a password issued for app A doesn't work in app B's subtree. Revoke them when a device or installation is retired.
-
Removing a user's last visible role locks them out of the app's LDAP immediately; their app passwords stay stored until revoked.
-
The service bind password is the OAuth client secret. Rotating the secret breaks the app's LDAP bind until its configuration is updated.
-
There is no bind rate limiting or lockout. Restrict the LDAP port with a firewall to the hosts that need it.