This is mostly vibe coded, with some manual changes from myself to handle certain issues.
My process was:
- Direct the LLM to focus on implementing the actual spec (so Claude went and named dropped the RFCs whereever it could; I think it's showing off)
- Once the initial package was completed, sanity check the unit tests to ensure they're testing what they claim and that they pass
- Generate an integration test suite against an openldap instance for objective measurements (once again, sanity checks)
It's definitely over-commented and over-engineered, but it seems to work. Use at your own risk, but if you see an issue feel free to submit a PR.
EVERYTHING BELOW IS CLAUDE OPUS 4.6
A pure-Swift LDAPv3 client with async/await support. No external dependencies — built entirely on Foundation.
Implements RFC 4511 (LDAPv3 protocol), RFC 4513 (authentication), RFC 4515 (search filter syntax), and RFC 4532 (Who Am I?).
- Swift 6.0+
- macOS 13+ / iOS 16+
Add SwiftLDAP to your Package.swift:
dependencies: [
.package(url: "https://github.com/elias-fox/SwiftLDAP.git", from: "1.0.0"),
],
targets: [
.target(
name: "MyApp",
dependencies: ["SwiftLDAP"]
),
]import SwiftLDAP
let client = LDAPClient(host: "ldap.example.com", security: .startTLS)
try await client.connect()
try await client.simpleBind(dn: "cn=admin,dc=example,dc=com", password: "secret")
let entries = try await client.search(
baseDN: "dc=example,dc=com",
filter: .equal("cn", "Jane Doe")
)
for entry in entries {
print(entry.dn)
print(" mail: \(entry.firstValue(for: "mail") ?? "n/a")")
}
try await client.unbind()LDAPClient is an actor — all operations are concurrency-safe.
| Mode | Default Port | Description |
|---|---|---|
.none |
389 | Plain-text (no encryption) |
.startTLS |
389 | Connects plain, upgrades to TLS before any credentials are sent |
.ldaps |
636 | TLS from the start |
// LDAPS on the default port (636)
let client = LDAPClient(host: "ldap.example.com", security: .ldaps)
// StartTLS on a custom port
let client = LDAPClient(host: "ldap.example.com", port: 3389, security: .startTLS)
// Disable certificate verification (testing only)
let client = LDAPClient(host: "localhost", security: .ldaps, tlsVerifyPeer: false)
// Cap search results at 1000 entries (guards against runaway servers)
let client = LDAPClient(host: "ldap.example.com", security: .ldaps, maxSearchEntries: 1000)Call connect() to establish the TCP connection. For .startTLS, the TLS handshake is performed automatically before connect() returns.
try await client.connect()For more control, pass an LDAPConnectionConfig directly:
let config = LDAPConnectionConfig(
host: "ldap.example.com",
port: 636,
security: .ldaps,
tlsVerifyPeer: true,
connectTimeout: 10,
operationTimeout: 30,
maxMessageSize: 10_485_760, // 10 MB (default)
maxSearchEntries: 1000 // 0 = no limit (default)
)
let client = LDAPClient(config: config)
try await client.connect()try await client.simpleBind(
dn: "cn=admin,dc=example,dc=com",
password: "secret"
)try await client.simpleBind()let (result, serverCreds) = try await client.saslBind(
mechanism: "EXTERNAL"
)Sends an unbind notification and closes the connection:
try await client.unbind()To close without notifying the server, use disconnect():
await client.disconnect()let entries = try await client.search(
baseDN: "ou=people,dc=example,dc=com",
scope: .wholeSubtree,
filter: .equal("objectClass", "inetOrgPerson"),
attributes: ["cn", "mail", "uid"]
)| Scope | Description |
|---|---|
.baseObject |
Only the entry named by baseDN |
.singleLevel |
Immediate children of baseDN |
.wholeSubtree |
Entire subtree below baseDN (default) |
For large result sets, use searchStream() to process entries one at a time without loading them all into memory:
let stream = try await client.searchStream(
baseDN: "dc=example,dc=com",
filter: .present(attribute: "mail")
)
for try await entry in stream {
print(entry.dn)
}LDAPEntry stores attribute values as [String: [Data]]. Use convenience methods to read string values:
let entry = entries.first!
// All values for an attribute
let emails = entry.stringValues(for: "mail")
// First value only
let cn = entry.firstValue(for: "cn")Filters can be built with static helpers or parsed from RFC 4515 strings.
// Equality
.equal("cn", "John Doe")
// Presence (attribute exists)
.exists("mail")
// Substring (supports leading/trailing/middle wildcards)
.substring("cn", "Jo*")
.substring("cn", "*Doe")
.substring("cn", "J*Do*")
// Comparison
.gte("uidNumber", "1000")
.lte("uidNumber", "2000")
// Approximate
.approx("cn", "Jon Doe")
// Boolean combinations
.and([.equal("objectClass", "person"), .exists("mail")])
.or([.equal("cn", "Alice"), .equal("cn", "Bob")])
.not(.equal("status", "disabled"))let filter = try LDAPFilter("(&(objectClass=person)(|(cn=John*)(mail=*@example.com)))")try await client.add(
dn: "cn=Jane Doe,ou=people,dc=example,dc=com",
attributes: [
LDAPAttribute(type: "objectClass", stringValues: ["inetOrgPerson"]),
LDAPAttribute(type: "cn", stringValues: ["Jane Doe"]),
LDAPAttribute(type: "sn", stringValues: ["Doe"]),
LDAPAttribute(type: "mail", stringValues: ["jane@example.com"]),
]
)Use the convenience methods for common operations:
// Replace an attribute's values
try await client.replaceAttribute(
dn: "cn=Jane Doe,ou=people,dc=example,dc=com",
attribute: "mail",
values: ["jane.doe@example.com"]
)
// Add values to an attribute
try await client.addAttribute(
dn: "cn=Jane Doe,ou=people,dc=example,dc=com",
attribute: "telephoneNumber",
values: ["+1-555-0100"]
)
// Delete specific values
try await client.deleteAttribute(
dn: "cn=Jane Doe,ou=people,dc=example,dc=com",
attribute: "telephoneNumber",
values: ["+1-555-0100"]
)
// Delete an entire attribute (omit values)
try await client.deleteAttribute(
dn: "cn=Jane Doe,ou=people,dc=example,dc=com",
attribute: "telephoneNumber"
)For multiple modifications in a single request, use modify() directly:
try await client.modify(
dn: "cn=Jane Doe,ou=people,dc=example,dc=com",
modifications: [
ModifyItem(
operation: .replace,
attribute: LDAPAttribute(type: "mail", stringValues: ["new@example.com"])
),
ModifyItem(
operation: .add,
attribute: LDAPAttribute(type: "description", stringValues: ["Updated entry"])
),
]
)try await client.delete(dn: "cn=Jane Doe,ou=people,dc=example,dc=com")// Rename (change RDN)
try await client.modifyDN(
dn: "cn=Jane Doe,ou=people,dc=example,dc=com",
newRDN: "cn=Jane Smith"
)
// Move to a different branch
try await client.modifyDN(
dn: "cn=Jane Smith,ou=people,dc=example,dc=com",
newRDN: "cn=Jane Smith",
newSuperior: "ou=managers,dc=example,dc=com"
)Cancel an in-flight operation by its message ID. The server silently discards the operation — no response is returned.
// abandon is rarely needed directly; most callers use task cancellation with searchStream
try await client.abandon(messageID: messageID)Test whether an entry has a specific attribute value without fetching the entry:
let match = try await client.compare(
dn: "cn=Jane Smith,ou=managers,dc=example,dc=com",
attribute: "title",
value: "Director"
)
// match == true if the attribute contains that valuelet identity = try await client.whoAmI()
print(identity) // e.g. "dn:cn=admin,dc=example,dc=com"Usually handled automatically when using .startTLS security mode. Can also be triggered manually on a plain connection:
let client = LDAPClient(host: "ldap.example.com", security: .none)
try await client.connect()
try await client.startTLS()
try await client.simpleBind(dn: "cn=admin,dc=example,dc=com", password: "secret")let (result, oid, value) = try await client.extendedOperation(
oid: "1.3.6.1.4.1.4203.1.11.3" // Who Am I? OID
)Attach LDAP controls to any operation that accepts them:
let entries = try await client.search(
baseDN: "dc=example,dc=com",
filter: .exists("cn"),
controls: [
LDAPControl(oid: "1.2.840.113556.1.4.319", criticality: true, value: controlValue)
]
)All operations throw LDAPError:
do {
try await client.simpleBind(dn: "cn=admin,dc=example,dc=com", password: "wrong")
} catch LDAPError.serverError(let code, let message, let matchedDN) {
// code == .invalidCredentials
print("Bind failed: \(message)")
} catch LDAPError.notConnected {
print("Not connected to server")
} catch LDAPError.connectionClosed {
print("Connection was closed")
} catch LDAPError.timeout {
print("Operation timed out")
}| Case | Description |
|---|---|
.serverError(resultCode:diagnosticMessage:matchedDN:) |
Server returned a non-success result code |
.notConnected |
Operation attempted without an active connection |
.connectionClosed |
Server closed the connection |
.protocolError(_) |
Malformed or unexpected protocol data |
.timeout |
Operation exceeded the configured timeout |
.invalidFilter(_) |
Filter string could not be parsed |
.tlsError(_) |
TLS negotiation failed |
.ioError(_) |
Underlying transport I/O error |
.unexpectedMessageID(expected:received:) |
Response message ID did not match the request |
See LICENSE for details.