Files
top-tickets/src/lib/ldap/client.ts
T
ogrechkoandClaude Sonnet 5 b8ca595d34 Shell out to ldapsearch for the directory browse instead of ldapjs
Bisecting through attribute selection and paging kept reproducing the
same failure in different shapes ("Encoding too long" BER parser error,
then ECONNRESET) — all while a plain `ldapsearch` against the exact same
query, run from inside this same container, completed successfully every
single time. That's strong enough evidence of a bug somewhere in
ldapjs/@ldapjs-asn1's BER decoding against this AD's actual response
bytes, not in our query. Rather than keep chasing a third-party parser
bug, searchLdapDirectory() now shells out to the system `ldapsearch`
(added to the image via ldap-utils) and parses its LDIF output directly
— the same tool that's already proven reliable here. authenticateLdapUser
(the per-login lookup) is untouched: it's a narrow single-match query
that has shown no sign of this issue, and isn't worth the added latency
of spawning a process on every login.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
2026-08-05 11:41:26 +00:00

254 lines
9.1 KiB
TypeScript

import ldap from "ldapjs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { getLdapSettings } from "@/lib/auth/ldap-config";
const execFileAsync = promisify(execFile);
export interface LdapUserInfo {
dn: string;
email: string;
name: string;
}
interface ConnectionSettings {
host: string;
port: number;
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
// "<id> 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<ldap.Client, Error>();
function createLdapClient(settings: ConnectionSettings, timeoutMs = 5_000): ldap.Client {
const protocol = settings.useTls ? "ldaps" : "ldap";
const client = ldap.createClient({
url: `${protocol}://${settings.host}:${settings.port}`,
// ldapjs's `timeout` is a single flat deadline for the whole operation
// (set once when the request is sent, not reset per entry received) —
// fine for a bind or a single-match search, but a directory-wide
// "(objectClass=person)" browse over the whole base DN can easily take
// longer than 5s on a real AD domain and gets killed mid-stream.
timeout: timeoutMs,
connectTimeout: 5_000,
});
// A socket-level error (e.g. host unreachable) with no listener would
// 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<void> {
return new Promise((resolve, reject) => {
client.bind(dn, password, (err) => (err ? reject(describeError(client, err)) : resolve()));
});
}
function search(
client: ldap.Client,
baseDn: string,
filter: string,
): Promise<{ dn: string; attributes: Record<string, string> }[]> {
return new Promise((resolve, reject) => {
const results: { dn: string; attributes: Record<string, string> }[] = [];
// `attributes` is restricted to just what callers actually read
// (mail/cn/displayName) instead of requesting every attribute AD has on
// an object. The original failure was @ldapjs/asn1's BER decoder
// throwing "Encoding too long" — a parser desync, most likely triggered
// while decoding some oversized/exotic AD attribute we never use anyway
// (e.g. nTSecurityDescriptor, msDS-ReplAttributeMetaData). Not requesting
// them sidesteps that bug entirely.
//
// Deliberately NOT using RFC 2696 paging here: with the response now
// this much smaller, an unpaged single request+response is simpler and
// matches what a plain `ldapsearch` against this same AD does — paging
// instead caused ldapjs's multiple sequential requests on one connection
// to get an ECONNRESET partway through.
client.search(
baseDn,
{ filter, scope: "sub", attributes: ["mail", "cn", "displayName"] },
(err, res) => {
if (err) {
reject(describeError(client, err));
return;
}
res.on("searchEntry", (entry) => {
const attributes: Record<string, string> = {};
for (const attr of entry.pojo.attributes) {
attributes[attr.type] = attr.values[0] ?? "";
}
results.push({ dn: entry.objectName ?? entry.pojo.objectName, attributes });
});
res.on("error", (searchErr) => reject(describeError(client, searchErr)));
res.on("end", () => resolve(results));
},
);
});
}
function unbindQuietly(client: ldap.Client): void {
client.unbind(() => {});
}
/** Un-folds RFC 2849 line continuations (a line starting with a single space extends the previous line). */
function unfoldLdif(raw: string): string[] {
const lines: string[] = [];
for (const line of raw.split("\n")) {
if (line.startsWith(" ") && lines.length > 0) {
lines[lines.length - 1] += line.slice(1);
} else {
lines.push(line);
}
}
return lines;
}
function parseLdif(raw: string): { dn: string; attributes: Record<string, string> }[] {
const entries: { dn: string; attributes: Record<string, string> }[] = [];
let current: { dn: string; attributes: Record<string, string> } | null = null;
for (const line of unfoldLdif(raw)) {
if (line === "") {
if (current) entries.push(current);
current = null;
continue;
}
const sepIndex = line.indexOf(":");
if (sepIndex === -1) continue;
const attr = line.slice(0, sepIndex);
const rest = line.slice(sepIndex + 1);
// `attr:: <base64>` for values needing encoding (binary or non-ASCII, e.g. Cyrillic DNs); plain `attr: value` otherwise.
const value = rest.startsWith(":")
? Buffer.from(rest.slice(1).trim(), "base64").toString("utf8")
: rest.trim();
if (attr === "dn") {
current = { dn: value, attributes: {} };
} else if (current) {
current.attributes[attr] = value;
}
}
if (current) entries.push(current);
return entries;
}
/**
* Shells out to the system `ldapsearch` (ldap-utils) instead of ldapjs for
* the directory-wide browse. `ldapjs`'s BER decoder was reproducibly
* throwing "Encoding too long" against this AD's search response,
* independent of attribute selection or paging, while a plain `ldapsearch`
* against the exact same query — run from inside this same container —
* completed cleanly every time. Rather than keep guessing at a bug in a
* third-party BER parser, use the client that's actually proven reliable
* here for this one operation.
*/
async function ldapSearchCli(
settings: ConnectionSettings & { bindDn: string; bindPassword: string },
baseDn: string,
filter: string,
): Promise<{ dn: string; attributes: Record<string, string> }[]> {
const protocol = settings.useTls ? "ldaps" : "ldap";
const { stdout } = await execFileAsync(
"ldapsearch",
[
"-x",
"-LLL",
"-H",
`${protocol}://${settings.host}:${settings.port}`,
"-D",
settings.bindDn,
"-w",
settings.bindPassword,
"-b",
baseDn,
filter,
"mail",
"cn",
"displayName",
],
{ maxBuffer: 20 * 1024 * 1024 },
);
return parseLdif(stdout);
}
/** Escapes an untrusted value for safe interpolation into an LDAP search filter (RFC 4515). */
function escapeFilterValue(value: string): string {
return value.replace(/[\\*()\0]/g, (c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
}
/** Binds with the service account only — used to validate settings before saving. Throws on failure. */
export async function testLdapBind(settings: ConnectionSettings & { bindDn: string; bindPassword: string }): Promise<void> {
const client = createLdapClient(settings);
try {
await bind(client, settings.bindDn, settings.bindPassword);
} finally {
unbindQuietly(client);
}
}
/**
* Search-then-bind: finds the user by userFilter, then re-binds as their
* own DN with the supplied password to actually verify it. Every failure
* mode (not configured, disabled, unreachable, no match, wrong password)
* normalizes to null — a directory outage must never break local login.
*/
export async function authenticateLdapUser(email: string, password: string): Promise<LdapUserInfo | null> {
const settings = await getLdapSettings();
if (!settings) return null;
const serviceClient = createLdapClient(settings);
try {
await bind(serviceClient, settings.bindDn, settings.bindPassword);
const filter = settings.userFilter.replace("{{email}}", escapeFilterValue(email));
const entries = await search(serviceClient, settings.baseDn, filter);
const match = entries[0];
if (!match) return null;
const userClient = createLdapClient(settings);
try {
await bind(userClient, match.dn, password);
} finally {
unbindQuietly(userClient);
}
return {
dn: match.dn,
email: match.attributes.mail || email,
name: match.attributes.cn || match.attributes.displayName || email,
};
} catch {
return null;
} finally {
unbindQuietly(serviceClient);
}
}
/** Broad directory browse (listFilter) for the "import accounts from LDAP" admin UI. */
export async function searchLdapDirectory(): Promise<LdapUserInfo[]> {
const settings = await getLdapSettings();
if (!settings) return [];
const entries = await ldapSearchCli(settings, settings.baseDn, settings.listFilter);
return entries
.filter((e) => e.attributes.mail)
.map((e) => ({
dn: e.dn,
email: e.attributes.mail,
name: e.attributes.cn || e.attributes.displayName || e.attributes.mail,
}));
}