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 // " 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({ 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 { 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 }[]> { return new Promise((resolve, reject) => { const results: { dn: string; attributes: Record }[] = []; // `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 = {}; 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 }[] { const entries: { dn: string; attributes: Record }[] = []; let current: { dn: string; attributes: Record } | 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:: ` 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 }[]> { 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 { 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 { 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 { 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, })); }