diff --git a/src/lib/ldap/client.ts b/src/lib/ldap/client.ts index d85c23e..cdeb6f1 100644 --- a/src/lib/ldap/client.ts +++ b/src/lib/ldap/client.ts @@ -13,6 +13,13 @@ interface ConnectionSettings { useTls: boolean; } +// ldapjs's client-level `error` event fires for things like a BER/protocol +// parser failure on the response stream — which then also forcibly closes +// the socket, so the pending bind/search callback only ever sees a generic +// " closed" ConnectionError with no hint of the real cause. Stashing the +// last `error` payload per client lets callers report the actual reason. +const lastClientError = new WeakMap(); + function createLdapClient(settings: ConnectionSettings, timeoutMs = 5_000): ldap.Client { const protocol = settings.useTls ? "ldaps" : "ldap"; const client = ldap.createClient({ @@ -26,15 +33,24 @@ function createLdapClient(settings: ConnectionSettings, timeoutMs = 5_000): ldap connectTimeout: 5_000, }); // A socket-level error (e.g. host unreachable) with no listener would - // throw and crash the process — swallow it here, every call site already - // handles failure via the bind/search callback's error argument. - client.on("error", () => {}); + // throw and crash the process — every call site already handles failure + // via the bind/search callback's error argument, so this only needs to + // record the error for that fallback, never rethrow. + client.on("error", (err) => { + lastClientError.set(client, err); + }); return client; } +/** Prefers the client's last captured `error` event over a generic connection-closed error, since the former usually carries the real cause. */ +function describeError(client: ldap.Client, err: Error): Error { + const captured = lastClientError.get(client); + return captured ?? err; +} + function bind(client: ldap.Client, dn: string, password: string): Promise { return new Promise((resolve, reject) => { - client.bind(dn, password, (err) => (err ? reject(err) : resolve())); + client.bind(dn, password, (err) => (err ? reject(describeError(client, err)) : resolve())); }); } @@ -55,7 +71,7 @@ function search( // stream of `searchEntry` events followed by a single `end`. client.search(baseDn, { filter, scope: "sub", paged: { pageSize: 200 } }, (err, res) => { if (err) { - reject(err); + reject(describeError(client, err)); return; } res.on("searchEntry", (entry) => { @@ -65,7 +81,7 @@ function search( } results.push({ dn: entry.objectName ?? entry.pojo.objectName, attributes }); }); - res.on("error", (searchErr) => reject(searchErr)); + res.on("error", (searchErr) => reject(describeError(client, searchErr))); res.on("end", () => resolve(results)); }); });