This library is a wrapper around ldapts providing convenience with a few core principles:
- Focus on promises and async iterators, do away with callbacks and event-emitting streams
- Always use a connection pool
- Hide everything having to do with acquiring/releasing connections
- Provide an easy way to configure with environment variables
- Provide lots of convenience methods and restructure return data for easier lookups
The major change in v3.0 is that the library now wraps ldapts instead of ldapjs, since ldapjs is no longer maintained. The ldap-async API remains the same, but in some cases we were accepting ldapjs-specific objects, such as Filter and Control objects. ldapts offers many of the same objects, but you will need to import them from ldapts instead of ldapjs.
Similarly if you are catching specific errors, you will need to compare against the ldapts error classes instead of the ldapjs ones. For example, if you were catching ldapjs.InvalidCredentialsError, you will now need to catch ldapts.InvalidCredentialsError instead.
An Ldap instance represents a connection pool. You will want to make a single pool and export it so that it can be imported in any other code file in your project.
import Ldap from 'ldap-async'
export const ldap = new Ldap({
// either
url: 'ldap://yourhost:10389',
// or
host: 'yourhost',
port: 10389,
secure: false,
// optional pool size (default is 5 simultaneous connections)
poolSize: 5,
// optional pings to prevent server dropping idle pool connections (default is disabled)
keepaliveSeconds: 60,
// number of seconds to keep an idle connection in the pool (default is 230 because
// Active Directory in Azure drops at 240)
idleTimeoutSeconds: 230,
// then your login and password
bindDN: 'cn=root',
bindCredentials: 'secret',
// optional: preserve attribute case in .toJSON() (default is false, which forces lower-casing)
// we lower-case all attributes in JSON objects because LDAP attribute names are case-insensitive,
// and this makes it easier to work with them without worrying about case - just always use lower-case
preserveAttributeCase: true,
// optional: mutate incoming entries as desired; this is synchronous, if you need async work you'll
// probably want to do it elsewhere in your code after getting an array of entries
transformEntries: (entry) => {
// modify the entry as needed
entry.set('fetchedAt', new Date().toISOString())
},
// optional StartTLS (default is false)
startTLS: false,
// optional StartTLS certificate (default is none)
startTLSCert: fs.readFileSync('/path/to/cert.pem'),
// and any other options supported by ldapts
timeout: 30000
})
async function main() {
const person = await ldap.get('cn=you,ou=people,dc=yourdomain,dc=com')
}
main().catch(e => console.error(e))When working in docker, it's common to keep configuration in environment variables. In order to make that easy, this library provides a convenient way to import a singleton pool created with the following environment variables:
LDAP_HOST
LDAP_PORT // default is 389 or 636 if you set LDAP_SECURE
LDAP_SECURE // set truthy to use ldaps protocol
LDAP_STARTTLS // set truthy to use StartTLS
LDAP_STARTTLS_CERT // full path to a certificate file, enables StartTLS
LDAP_DN // the DN with which to bind
LDAP_PASS // the password for the bind DN
LDAP_POOLSIZE (default: 5)
LDAP_KEEPALIVE_SECONDS // enables keepalive pings at the socket level (default: disabled)
LDAP_IDLE_TIMEOUT_SECONDS // number of seconds to keep an idle connection in the pool (default: 230)
LDAP_PRESERVE_ATTRIBUTE_CASE // set truthy to disable forced lower-casing of attributes in .toJSON()
This way, connecting is very simple, and you don't have to worry about creating a singleton pool for the rest of your codebase to import, because it's done for you:
import ldap from 'ldap-async/client'
async function main() {
const person = await ldap.get('cn=you,ou=people,dc=yourdomain,dc=com')
}
main().catch(e => console.error(e))You must refer to .default when importing with require:
const Ldap = require('ldap-async').default
// or the instance created with environment variables (see above)
const ldap = require('ldap-async/client').defaultConvenience methods are provided that allow you to specify the kind of operation you are about to do and the type of return data you expect.
There are four main ways to query LDAP data:
get- get a single entry by DN, this sets the search scope to 'base' by default and returns the first result if multiple are foundsearch- search for multiple entries and return them all as an array, search scope defaults to 'sub'stream- search for multiple entries and return them as a stream (async iterator), search scope defaults to 'sub'getMembers- get all non-group members of a group (nested groups are recursively expanded)
const person = await ldap.get('cn=you,ou=people,dc=yourdomain,dc=com')
console.log(person.toJSON()) // { givenName: 'John', ... }
const people = await ldap.search('ou=people,dc=yourdomain,dc=com', { scope: 'sub', filter: 'objectclass=person' })
console.log(people.map(p => p.toJSON())) // [{ givenName: 'John', ... }, { givenName: 'Mary', ... }]
const people = await ldap.stream('ou=people,dc=yourdomain,dc=com', { scope: 'sub', filter: 'objectclass=person' })
for await (const p of people) {
console.log(p.toJSON()) // { givenName: 'John', ... }
}
const people = await ldap.getMembers('cn=yourgroup,ou=groups,dc=yourdomain,dc=com')
console.log(people.map(p => p.toJSON())) // [{ givenName: 'John', ... }, { givenName: 'Mary', ... }]In ldap-async v2.0 the return object changed to give you greater control over the return type you want/expect. Now you get a special LdapEntry class with methods for getting the entry attributes:
const entry = await ldap.get(... whatever ...)
entry.get('givenName') // 'John' - you can also use entry.one('givenName') or entry.first('givenName')
entry.all('givenName') // ['John']
entry.buffer('givenName') // Buffer.from('John', 'utf8')
entry.buffers('givenName') // [Buffer.from('John', 'utf8')]If you want something more like the ldap-async v1.0 return object, use the .toJSON() method. You'll
get back an object with attribute names as the keys and the values will be a mixture of string and
string[]. The attribute names will be lower-case: see the preserveAttributeCase setting. Attributes
with only one value will be string, attributes with multiple values will be string[]. Attributes with
at least one value that is not valid UTF-8 (usually binaries like image data) will be base64 encoded strings.
Note that there is also a .set() method on LdapEntry. This is for creating new data on an entry for
use elsewhere in your code (e.g. with the transformEntries option). It does not modify the LDAP
server. To modify the LDAP server, use the methods described in the "Writing" section below.
We also have convenience methods for parsing dates out of LDAP date strings, .date() and .dates(), which
work similarly to .get() and .all(), but return Date objects.
const createdAt = entry.date('createTimestamp')These methods automatically detect various date formats: LDAP standard generalized time format,
windows filetime timestamps (common in Active Directory), unix epoch timestamps, and iso 8601 date strings.
You can also avoid the autodetection by providing 'ldap', 'windows', 'unix', 'millis' (unix timestamp
including milliseconds), or 'iso' as the second parameter.
const createdAt = entry.date('pwdLastSet', 'windows') // pwdLastSet is common in Active DirectoryThere's a bit of a gotcha when accessing multi-value attributes with .all() - some LDAP servers (notably
Active Directory) limit the number of values returned. For example, if a group has more than 1500 members,
only the first 1500 will be returned by default. If you use entry.all('member'), you will only get those first
1500 members and miss the rest and you won't know they are missing.
Unfortunately, it would be expensive (and asynchronous) to always page through all multi-valued attributes, so we make you explicitly ask for it when you need it.
If you want to be sure you get all values for a multi-valued attribute, use the fullRange method:
const entry = await ldap.get(... whatever ...)
const allMembers = await entry.fullRange('member') // always returns a string[]This method must be awaited because it may need to make additional requests to the LDAP server to page through all the values.
It's also possible to stream the values of a multi-valued attribute using the pages method. See
the "Streaming" section below for details.
// change the value of a single attribute on a record
await ldap.setAttribute('cn=you,ou=people,dc=yourdomain,dc=com', 'email', 'newemail@company.com')
// change the value of multiple attributes in one round trip
await ldap.setAttributes('cn=you,ou=people,dc=yourdomain,dc=com', { email: 'newemail@company.com', sn: 'Smith' })
// pushes value onto an array attribute unless it's already there
await ldap.pushAttribute('cn=you,ou=people,dc=yourdomain,dc=com', 'email', 'newemail@company.com')
// remove a value from an array attribute (returns true without doing anything if value wasn't there)
await ldap.pullAttribute('cn=you,ou=people,dc=yourdomain,dc=com', 'email', ['newemail@company.com'])
// remove an attribute entirely
await ldap.removeAttribute('cn=you,ou=people,dc=yourdomain,dc=com', 'customAttr')
// add a full record
await ldap.add('cn=you,ou=people,dc=yourdomain,dc=com', { /* a person record */ })
// remove a full record
await ldap.remove('cn=you,ou=people,dc=yourdomain,dc=com')
// rename a record (in this example only the cn changes, the ou,dc entries are preserved)
await ldap.modifyDN('cn=you,ou=people,dc=yourdomain,dc=com', 'cn=yourself')
// special group membership functions
await ldap.addMember('cn=you,ou=people,dc=yourdomain,dc=com', 'cn=yourgroup,ou=groups,dc=yourdomain,dc=com')
await ldap.removeMember('cn=you,ou=people,dc=yourdomain,dc=com', 'cn=yourgroup,ou=groups,dc=yourdomain,dc=com')When you construct LDAP search query strings, it's important to escape any input strings to prevent injection attacks. LDAP has two kinds of strings with different escaping requirements, so we provide a template literal helper for each.
For DN strings, use ldap.dn:
const person = await ldap.get(ldap.dn`cn=${myCN},ou=people,dc=yourdomain,dc=com`)For filter strings, use ldap.filter:
const people = await ldap.search('ou=people,dc=yourdomain,dc=com', {
scope: 'sub',
filter: ldap.filter`givenName=${n}`
})More complex queries may also use ldap.filter inside a map function, such as this one that finds many users by their names:
const people = await ldap.search('ou=people,dc=yourdomain,dc=com', {
scope: 'sub',
filter: `(|${myNames.map(n => ldap.filter`(givenName=${n})`).join('')})`
})For convenience, a few helper functions are provided to help you construct LDAP filters: in, any, all, and anyall. These functions take care of escaping for you.
- Everyone named John or Mary:
ldap.in(['John', 'Mary'], 'givenName') // => '(|(givenName=John)(givenName=Mary))
- Everyone named John or with the surname Smith
ldap.any({ givenName: 'John', sn: 'Smith' }) // => '(|(givenName=John)(sn=Smith))
- Everyone named John Smith
ldap.all({ givenName: 'John', sn: 'Smith' }) // => '(&(givenName=John)(sn=Smith))
- Everyone named John Smith or Mary Scott
ldap.anyall([{ givenName: 'John', sn: 'Smith' }, { givenName: 'Mary', sn: 'Scott' }]) // => '(|(&(givenName=John)(sn=Smith))(&(givenName=Mary)(sn=Scott)))'
Note that any, all and anyall can accept an optional wildcard parameter if you want users to be able to provide wildcards. Other special characters like parentheses will be properly escaped.
- Everyone named John whose surname starts with S
ldap.all({ givenName: 'John', sn: 'S*' }, true) // => '(&(givenName=John)(sn=S*))
ldapts offers a "Filters API" that helps you create (and parse) filters. You're free to use that instead of the filter helpers provided by ldap-async. Just make a filter object with their Filters API and give it to any appropriate method in ldap-async:
import { EqualityFilter } from 'ldapts'
const people = await ldap.search('ou=people,dc=yourdomain,dc=com', {
scope: 'sub',
filter: new EqualityFilter({ attribute: 'givenName', value: n })
})To avoid using too much memory on huge datasets, we provide a stream method that performs the same as search but returns a node Readable. It is recommended to use the async iterator pattern:
const stream = ldap.stream('ou=people,dc=yourdomain,dc=com', {
scope: 'sub',
filter: ldap.in(myNames, 'givenName')
})
for await (const person of stream) {
/* do some work on the person */
}for await is very safe, as breaking the loop or throwing an error inside the loop will clean up the stream appropriately.
Since .stream() returns a Readable in object mode, you can easily do other things with
it like .pipe() it to another stream processor. When using the stream without for await, you must call stream.destroy() if you do not want to finish processing it and carefully use try {} finally {} to destroy it in case your code throws an error. Failure to do so will leak a connection from the pool.
There is also a getMemberStream function which performs the same as getMembers:
const people = ldap.getMemberStream('cn=yourgroup,ou=groups,dc=yourdomain,dc=com')
for await (const p of people) { /* do some work on the person */ }Finally, you may wish to stream the paged values of a multi-valued attribute on a single LDAP entry. You can do that with the pages() method on LdapEntry:
const group = await ldap.get('cn=yourgroup,ou=groups,dc=yourdomain,dc=com')
const memberStream = group.pages('member')
for await (const memberDns of memberStream) {
/* do some work on the memberDns (array of member DNs) */
}This is a simple async iterator.
Some LDAP services store binary data as properties of records (e.g. user profile photos). In ldap-async v1.0, we provided a _raw property to work around this. In v2.0 we supported it with the new LdapEntry return object. Just access the binary data using the .buffer() method.
const people = await ldap.search('ou=people,dc=yourdomain,dc=com', {
scope: 'sub',
filter: ldap.in(myNames, 'givenName')
})
for (const person of people) {
const photoBuffer = person.buffer('jpegPhoto') // get the jpegPhoto as a Buffer
// do something with the photoBuffer, like saving it to a file or processing it
}If you use one of the other methods like .one() or .toJSON(), it will return base64 encoded data for any binaries. So to convert profile photos to data URLs, you could do something like this:
const user = (await ldap.get<{ jpegphoto: string }>(userDn)).toJSON()
const convertedUser = {
...user,
jpegphotourl: `data:image/jpeg;base64,${user.jpegphoto}`
}Generally you want to let the pool do its thing for the entire life of your process, but if you are sure you're done with it, you can call await client.close() and it will wait for all existing requests to finish, then empty the pool so that everything can be garbage collected. The pool is still valid, so if you make another request, the pool will open back up and work normally.
This library is written in typescript and provides its own types. For added convenience, methods that return
objects will accept a generic so that you can specify the return type you expect from the .toJSON() method:
interface LDAPPerson {
cn: string
givenName: string
}
const person = ldap.get<LDAPPerson>(ldap.dn`cn=${myCN},ou=people,dc=yourdomain,dc=com`)
// person.toJSON() will be an LDAPPerson