Skip to content

@lakutata/ldap ​

LDAP for the lakutata framework. The LDAPServer component serves a directory from the application: its backends hold the entries (the DirectoryBackend keeps them in the application's database), and the application reads and writes them without the network. The LDAPClient component gives an application a pool of connections to an LDAP server (OpenLDAP, Active Directory, 389 Directory Server…). Both are built on @lakutata/ldap-engine, which this package re-exports.

Installation ​

shell
npm install lakutata @lakutata/ldap

Node.js >=18.10.0, Bun, Electron and NW.js. To listen on network interfaces, also install @lakutata/netif.

Usage ​

The server ​

typescript
import {readFileSync} from 'node:fs'
import {Application} from 'lakutata'
import {buildLDAPServerOptions, type LDAPServer, MemoryBackend} from '@lakutata/ldap'

Application.run(() => ({
    id: 'directory.app',
    name: 'Directory',
    components: {
        ldap: buildLDAPServerOptions({
            port: 389,
            ldapsPort: 636,
            tls: {cert: readFileSync('server.crt'), key: readFileSync('server.key')},
            backends: [new MemoryBackend('dc=example,dc=com')],
            access: {admins: ['cn=admin,dc=example,dc=com']}
        })
    },
    bootstrap: ['ldap']
})).onLaunched(async (app) => {
    const ldap: LDAPServer = await app.getObject('ldap')
    const directory = ldap.directory('cn=admin,dc=example,dc=com')
    await directory.add('dc=example,dc=com', {objectClass: ['dcObject', 'organization'], dc: 'example', o: 'Example'})
    const people = await directory.createOrganizationalUnit('dc=example,dc=com', {ou: 'people'})
    await directory.createUser(people, {uid: 'alice', cn: 'Alice Liu', sn: 'Liu'}, 'secret')
})

The component listens for LDAP (with StartTLS when tls is given) and LDAPS, on an address or on network interfaces (interface: 'eth1', whatever its addresses). With ldapiPath it also listens on a Unix socket, which takes passwords as TLS does, for the clients on the same machine (SSSD, nslcd); port: false leaves out the port of LDAP. A backend, a plugin or an interceptor can be an object (the plugins of @lakutata/ldap-engine: new MemberOfPlugin()), or a lakutata class with its options ({class: DevicesBackend, suffix: 'ou=devices,dc=example,dc=com'}), built in the component's container. connect() gives a connection in the same process with the operations of the client, and directory() gives the directory helper on such a connection; both go through the same schema checks, password hashing and access control as the network requests. What the server does (the schema, the passwords, the access rules, the plugins) is described in the README of @lakutata/ldap-engine.

The directory backend ​

typescript
import {Application} from 'lakutata'
import {buildDatabaseOptions} from 'lakutata/com/database'
import {buildLDAPServerOptions, DirectoryBackend, directoryEntities} from '@lakutata/ldap'

Application.run(() => ({
    id: 'directory.app',
    name: 'Directory',
    components: {
        database: buildDatabaseOptions({type: 'postgres', url: process.env.DATABASE_URL, entities: directoryEntities('postgres')}),
        ldap: buildLDAPServerOptions({
            port: 389,
            backends: [{
                class: DirectoryBackend,
                database: 'database',
                suffix: 'dc=example,dc=com',
                unique: ['uid', 'mail'],
                initialize: {admin: {dn: 'cn=admin,dc=example,dc=com', password: process.env.LDAP_ADMIN_PASSWORD!}}
            }],
            access: {admins: ['cn=admin,dc=example,dc=com']},
            passwordPolicy: {default: {lockout: true, maxFailure: 5, lockoutDuration: 900, maxAge: 7776000, expireWarning: 1209600}}
        })
    },
    bootstrap: ['database', 'ldap']
}))

The directory backend keeps the entries of its suffix in the database of the application (PostgreSQL, MySQL or SQLite): one table of entries and one row per value, whatever their object classes, so the schema of the server decides what an entry holds. Add directoryEntities(type), given the type of the database (MySQL keeps the values in a LONGTEXT column), to the entities of the database; the migrations are the application's, as for its other entities.

  • Searches are run by the database: the filters become SQL on the forms of the values the matching rules compare by (equality, substrings, integers and times), and the server checks what SQL cannot decide exactly.
  • A sorted search (the sort control, and the window of the virtual list view) is sorted by the backend, which reads only the entries of the window: a window by an offset is ordered, counted and cut by the database on a sort key kept with each value; otherwise the backend reads the values of the sort keys and orders the entries by them as the server would. A sort key follows the ordering rule of its attribute when the value is written: after the schema changes that rule, the values written before keep their old keys until they are written again (OpenLDAP likewise rebuilds its indexes with slapindex).
  • The entries of a search come in a set order: an entry before the entries below it, siblings in the order they were added (entryUUID is a UUID of version 7, whose order is the order of creation; an entry imported with its own entryUUID takes its place by that value). Entries with equal sort keys keep this order.
  • The DN values of member, manager, seeAlso and the like point at their entries: they follow a rename, and go when the entry is deleted (a required attribute left empty keeps a placeholder, the empty DN by default).
  • memberOf is computed when it is read, and a filter on it finds the members of a group; nestedGroups follows nested groups. memberUid is computed for the posixGroup entries, for the clients of RFC 2307.
  • uidNumber and gidNumber are allocated to the posixAccount and posixGroup entries added without them (from 10000); a uidNumber is unique, and so is the gidNumber of a posixGroup among the groups. The POSIX classes come with the bundled schema rfc2307bis, which schema.bundled names (it is not loaded by default).
  • The values of the attributes of unique are unique in the suffix; the time of the last bind is given as authTimestamp; the failed binds are kept in the database, so the instances of an application share them, as they share the state of the password policy; mailAlias gives email as mail.
  • Each write runs in a transaction, with the interceptors' beforeCommit().
  • The plugins of @lakutata/ldap-engine doing what the backend does itself (MemberOfPlugin, ReferentialIntegrityPlugin, IdAllocationPlugin, LastBindPlugin) leave its chain; the others (UniquePlugin, AuditPlugin, ConstraintPlugin…) run for it as for any backend.
  • initialize creates the entry of the suffix and an administrator on the first start (or imports LDIF), once.

The client ​

typescript
import {Application} from 'lakutata'
import {buildLDAPClientOptions, filter, type LDAPClient} from '@lakutata/ldap'

Application.run(() => ({
    id: 'directory.app',
    name: 'Directory',
    components: {
        ldap: buildLDAPClientOptions({
            url: 'ldaps://dc1.example.com',
            bindDN: 'CN=svc-app,OU=Service,DC=example,DC=com',
            password: process.env.LDAP_PASSWORD
        })
    }
})).onLaunched(async (app) => {
    const ldap: LDAPClient = await app.getObject('ldap')
    const name: string = 'alice'
    const {entries} = await ldap.search('DC=example,DC=com', {
        filter: filter`(&(objectClass=user)(sAMAccountName=${name}))`,
        attributes: ['displayName', 'mail', 'memberOf']
    })
    console.log(entries[0]?.get('memberOf'))

    const result = await ldap.authenticate({username: 'alice', password: 'secret', base: 'DC=example,DC=com', attribute: 'sAMAccountName'})
    console.log(result.ok ? result.dn : result.reason)
})

The component has the operations of an LDAP connection (search(), searchIterator(), compare(), add(), modify(), delete(), modifyDN(), extended(), passwordModify(), whoAmI()), authenticate() to check a user's password on a connection of its own, readSchema(), and connect() for a connection apart from the pool. Its connections open on the first operation and close with the application. Searches are paged, and a result that is not a success throws its exception. authenticate() sends passwords only over TLS (an ldaps URL or startTLS: true) or a local ldapi socket, unless it is given requireTLS: false.

Options ​

LDAPServer ​

OptionDefaultWhat it is
hostall addressesThe address listened on
port389The port of LDAP (0 for a free one, false for none)
ldapsPortThe port of LDAPS, with tls
ldapiPathThe path of a Unix socket listened on as well (ldapi), for the clients on the same machine; passwords are taken over it as over TLS
ldapiModefrom the umaskThe permissions of the socket, which decide who may connect (0o666: every user of the machine)
tlsThe certificate and key of StartTLS and LDAPS (cert, key); for SASL EXTERNAL, requestCert: true and the CA of the client certificates in ca
interface, strict, family, onError, interfaceListeningListen on network interfaces instead of an address
backendsThe backends: objects, or lakutata classes with their options
pluginsThe plugins: objects, or lakutata classes with their options (and name, suffixes)
interceptorsThe interceptors alone: objects, placements, or lakutata classes with their options
schemacore, cosine, inetorgpersonbundled (the bundled schemas by name; rfc2307bis, openssh-lpk and ppolicy are added by naming them, [] leaves the system schema alone), directory (a directory of schema files, loaded in the order of their names) and files (schemas of one's own: paths, texts, or documents of readSchemaFile()/parseSchemaFile())
accessadmins, passwordAdmins, anonymousRead, authenticatedRead, check
limitsThe size, time, message, request and connection limits
bindThrottle5 failures lock the DNThe limits on failed binds
plaintextBind'all'Where a password is accepted without TLS: 'all', 'loopback', 'none' (the Unix socket takes it whatever this says)
allowUnauthenticatedBindfalseAccept a bind with a name and no password as anonymous
passwords{PBKDF2-SHA512}The scheme passwords are written in, and the iterations of PBKDF2
passwordPolicyThe password policy (a PasswordPolicyPlugin after the plugins): its default policy (the DN of a pwdPolicy entry, or its rules), quality, revealLocked
rehashOnBindtrueHash a password again in the scheme of the server after a bind with a weaker one
saslPLAIN, EXTERNALThe SASL mechanisms
extendedOperationsExtended operations of one's own
vendorName, vendorVersion, defaultNamingContext, rootDSEAttributeslakutataWhat the root DSE says
schemaConfigViewfalseShow the schema files read-only as OpenLDAP does (cn=schema,cn=config)

DirectoryBackend ​

OptionDefaultWhat it is
suffixThe DN of the top entry of the backend
databaseThe name the Database component of the application is registered under (its key in components); required
groupsgroupOfNames/member, groupOfUniqueNames/uniqueMember, groupOfMembers/memberThe group classes and their member attributes
memberOftrueGive memberOf; a name and an OID of one's own when the schema defines memberOf itself, or false
nestedGroups0The levels of nested groups memberOf follows
memberUidtrueGive memberUid to the posixGroup entries
uniqueThe attributes whose values are unique in the suffix
idAllocationfrom 10000The first uidNumber and gidNumber allocated, or false
lastBindtrueGive authTimestamp, the time of the last successful bind
mailAliasfalseGive email, the values of mail
referencePlaceholderthe empty DNThe value left in a required DN attribute whose last value named a deleted entry
sharedThrottletrueKeep the failed binds in the database
initializeroot, ldif and admin: what the first start creates

LDAPClient ​

OptionDefaultWhat it is
urlThe LDAP URL of the server, or of several tried in order
srvA DNS SRV record naming the servers, instead of url
bindDN, passwordanonymousThe identity of the pool
tlsThe options of TLS (ca, cert, key, rejectUnauthorized…)
startTLSfalseRun StartTLS on each connection of an ldap:// URL
poolSize4The most connections of the pool
connectTimeout5000The milliseconds a connection may take
timeout30000The milliseconds an operation may take before it is abandoned (0 for none)
idleTimeout600000Close a connection idle that many milliseconds
maxMessageSize64 MiBThe largest message a server may send
binaryAttributesMore attributes whose values are bytes

API ​

ExportWhat it is
LDAPServer, buildLDAPServerOptions(options), LDAPServerOptionsThe server component and its options
LDAPClient, buildLDAPClientOptions(options), LDAPClientOptionsThe client component and its options
DirectoryBackend, DirectoryBackendOptions, directoryEntities(type)The directory backend, its options, its entities for a type of database
LDAPEntryRecord, LDAPValueRecord, LDAPMySQLValueRecord, LDAPUniqueRecord, LDAPBindStateRecordThe entities of the directory backend
ObjectSpecification, PluginSpecification, PluginObjectSpecification, InterceptorSpecification, InterfaceListeningOptionsThe types of the options
Everything @lakutata/ldap-engine exportsThe protocol, the client, the server engine, the directory helper
@lakutata/ldap/schemasThe bundled schemas as documents (CORE, RFC2307BIS…, and DEFAULTS), those of @lakutata/ldap-engine/schemas

Released under the MIT License.