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

cn=service,o=<client_id>,<base_dn>, password = the client secret

User account

uid=<username>,ou=people,o=<client_id>,<base_dn>, password = an app password

Visibility

users and groups of the roles the app was granted with ldap:search:<role>

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

listen

Socket address ip:port ([::]:636 for IPv6). Hostnames are not accepted. There is no default port; 636 is the standard LDAPS port and needs privileges to bind (for example CAP_NET_BIND_SERVICE).

base_dn

Suffix of every DN, for example dc=example,dc=com. Must be a non-empty DN; multi-valued RDNs (+) and #-hex values are rejected.

certificate

PEM certificate chain served to LDAP clients (leaf first).

private_key

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 ldap object is present but the files can't be read, base_dn is invalid or listen can'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 for ldaps:// / "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

objectClass

top, person, organizationalPerson, inetOrgPerson

yes

uid

current username

yes

cn

profile name, or the username if no name is set

yes

displayName

same as cn

yes

mail

every verified email address of the user (multi-valued, may be absent)

yes

memberOf

DN of every visible group the user is in

equality with a full group DN, presence

deltaUserId

pykrete user id (U...), stable across username changes

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

objectClass

top, groupOfNames

yes

cn

role id

yes

displayName

role name

yes

description

role description, omitted when empty

yes

member

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: top plus its first RDN as an attribute (for example dc: 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

  1. 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.

  2. 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 }
        }
      }
    }
  3. Make the roles visible to the app. setClientRoles replaces 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 } } }.

  4. 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 memberOf values 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 (user, client) pair, identified by a name that is unique per user and client

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).

Note
Users can't issue or revoke app passwords themselves yet; the dashboard page is read-only.

Binding

Identity Bind DN Password

Anonymous

empty

empty

App (service)

cn=service,o=<client_id>,<base_dn>

client secret of that client

User

uid=<username>,ou=people,o=<client_id>,<base_dn>

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 uid in 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 insufficientAccessRights.

App (service)

Everything in its own o=<client_id> subtree: all visible users and groups.

User

The containers and its own user entry only (including its memberOf); no groups, no other users.

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

auth.infra.delta.rocks, the port from listen

Encryption

LDAPS / "SSL/TLS" (implicit TLS), not StartTLS

Bind DN / manager DN

cn=service,o=<client_id>,dc=example,dc=com

Bind password

the client secret

User base DN

ou=people,o=<client_id>,dc=example,dc=com

User filter

(&(objectClass=inetOrgPerson)(uid=%s)) (placeholder syntax depends on the app)

Login / username attribute

uid

Unique id attribute

deltaUserId

Email / display name

mail / displayName

Group base DN

ou=groups,o=<client_id>,dc=example,dc=com

Group filter / object class

(objectClass=groupOfNames) / groupOfNames

Group membership

member on groups (user DN), or memberOf on users

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; typesOnly; attribute selection

Extended: WhoAmI (1.3.6.1.4.1.4203.1.11.3)

returns dn:<bound DN>, empty when anonymous

Other extended operations (StartTLS, password modify, ...)

protocolError

Add, Modify, Delete, ModifyDN, Compare

unwillingToPerform

Abandon

ignored

Unbind

closes the connection

Controls

simple paged results (1.2.840.113556.1.4.319) only

Search details:

  • Attribute selection: an empty list, * or + returns all attributes; 1.1 returns none. Attribute names are case-insensitive and the aliases userid, commonName, rfc822mailbox, organizationName, organizationalUnitName and domainComponent are 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.

  • memberOf and member filters 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.