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
254 lines
9.1 KiB
TypeScript
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,
|
|
}));
|
|
}
|