import ldap from "ldapjs";
|
import type { LdapConfig } from "../config/types.js";
|
|
/** Escape value for use inside LDAP filter (RFC4515 subset). */
|
export function escapeLdapFilterValue(value: string): string {
|
return value.replace(/[\\*()\0]/g, (ch) => {
|
const code = ch.charCodeAt(0);
|
return `\\${code.toString(16).padStart(2, "0")}`;
|
});
|
}
|
|
function bindAsync(
|
client: ldap.Client,
|
dn: string,
|
password: string
|
): Promise<void> {
|
return new Promise((resolve, reject) => {
|
client.bind(dn, password, (err) => {
|
if (err) reject(err);
|
else resolve();
|
});
|
});
|
}
|
|
function unbindSafe(client: ldap.Client): void {
|
try {
|
client.unbind();
|
} catch {
|
/* ignore */
|
}
|
}
|
|
function searchUserDn(
|
client: ldap.Client,
|
searchBase: string,
|
filter: string
|
): Promise<string | null> {
|
return new Promise((resolve, reject) => {
|
const opts: ldap.SearchOptions = {
|
filter,
|
scope: "sub",
|
attributes: ["dn"],
|
sizeLimit: 1,
|
};
|
client.search(searchBase, opts, (err, res) => {
|
if (err) {
|
reject(err);
|
return;
|
}
|
let dn: string | null = null;
|
res.on("searchEntry", (entry) => {
|
dn = entry.dn.toString();
|
});
|
res.on("error", reject);
|
res.on("end", (result) => {
|
if (result?.status !== 0 && result?.status !== undefined) {
|
reject(new Error(`LDAP search end status ${result.status}`));
|
return;
|
}
|
resolve(dn);
|
});
|
});
|
});
|
}
|
|
export type LdapVerifyResult =
|
| { ok: true }
|
| { ok: false; reason: "invalid_credentials" | "user_not_found" | "connection_error" };
|
|
/**
|
* Verify username/password against AD/LDAP:
|
* service bind → search user DN → bind as user with password.
|
*/
|
export async function verifyCredentials(
|
username: string,
|
password: string,
|
cfg: LdapConfig
|
): Promise<LdapVerifyResult> {
|
const user = (username ?? "").trim();
|
if (!user || !password) {
|
return { ok: false, reason: "invalid_credentials" };
|
}
|
|
const filter = cfg.searchFilter.replace(
|
/\{\{username\}\}/g,
|
escapeLdapFilterValue(user)
|
);
|
|
const clientOpts: ldap.ClientOptions = { url: cfg.url };
|
if (cfg.tlsOptions) {
|
clientOpts.tlsOptions = { ...cfg.tlsOptions };
|
}
|
|
const searchClient = ldap.createClient(clientOpts);
|
try {
|
await bindAsync(searchClient, cfg.bindDN, cfg.bindPassword);
|
const userDn = await searchUserDn(searchClient, cfg.searchBase, filter);
|
unbindSafe(searchClient);
|
if (!userDn) {
|
return { ok: false, reason: "user_not_found" };
|
}
|
|
const verifyClient = ldap.createClient(clientOpts);
|
try {
|
await bindAsync(verifyClient, userDn, password);
|
unbindSafe(verifyClient);
|
return { ok: true };
|
} catch {
|
unbindSafe(verifyClient);
|
return { ok: false, reason: "invalid_credentials" };
|
}
|
} catch {
|
unbindSafe(searchClient);
|
return { ok: false, reason: "connection_error" };
|
}
|
}
|