From 378be3405a8cf39f1d4b7601a7464b6bbdab4280 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 27 Sep 2026 13:51:18 +0800 Subject: [PATCH] fix(ldap): preserve the directory-provided bind DN (#1486) --- plugins/ldap/src/backend/ldap.ts | 5 +++- plugins/ldap/tests/backend/ldap.test.ts | 40 ++++++++++++++++++++----- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/plugins/ldap/src/backend/ldap.ts b/plugins/ldap/src/backend/ldap.ts index 05d6a4822..227b3ea60 100644 --- a/plugins/ldap/src/backend/ldap.ts +++ b/plugins/ldap/src/backend/ldap.ts @@ -141,12 +141,15 @@ export function createLdapLogin(ctx: PluginContext, store: ProviderStore) { config.displayNameAttribute || "cn", "mail", "email", + "distinguishedName", ], ); if (entries.length === 0) throw await refuse("user not found"); const userEntry = entries[0]; - const userDN = userEntry.dn.toString(); + // AD supplies its DN as an attribute; avoid re-serializing it through ldapjs. + const userDN = + firstValue(userEntry, "distinguishedName") || userEntry.dn.toString(); const uid = firstValue(userEntry, config.usernameAttribute || "uid") || username; const displayName = diff --git a/plugins/ldap/tests/backend/ldap.test.ts b/plugins/ldap/tests/backend/ldap.test.ts index b4f90f7e0..0e7b8cd95 100644 --- a/plugins/ldap/tests/backend/ldap.test.ts +++ b/plugins/ldap/tests/backend/ldap.test.ts @@ -11,7 +11,9 @@ const directory = vi.hoisted(() => ({ binds: [] as string[], })); -vi.mock("ldapjs", () => { +vi.mock("ldapjs", async () => { + const { default: ldap } = + await vi.importActual("ldapjs"); function createClient() { return { bind: (dn: string, password: string, cb: (err?: Error) => void) => { @@ -25,7 +27,7 @@ vi.mock("ldapjs", () => { }, search: ( base: string, - options: { filter: string }, + options: { filter: string; attributes: string[] }, cb: (err: Error | null, res: unknown) => void, ) => { const listeners: Record void> = {}; @@ -49,11 +51,13 @@ vi.mock("ldapjs", () => { const user = match ? directory.users[match[1]] : undefined; if (user) { listeners.searchEntry?.({ - dn: { toString: () => user.dn }, - attributes: Object.entries(user.attrs).map(([type, value]) => ({ - type, - values: [value], - })), + dn: ldap.parseDN(user.dn), + attributes: Object.entries(user.attrs) + .filter(([type]) => options.attributes.includes(type)) + .map(([type, value]) => ({ + type, + values: [value], + })), }); } } @@ -121,6 +125,28 @@ function verify( } describe("LDAP sign-in", () => { + it.each([ + "CN=Bob,OU=Первый Департамент,DC=example,DC=com", + String.raw`CN=Bob\, Builder,OU=Первый Департамент,DC=example,DC=com`, + String.raw`CN=Bob\5cBuilder,OU=People,DC=example,DC=com`, + ])( + "binds with the directory's original distinguishedName: %s", + async (dn) => { + const { s, id } = await withDirectory(); + directory.users.bob.dn = dn; + directory.users.bob.attrs.distinguishedName = dn; + const identity = await verify(s, String(id), { + username: "bob", + password: "hunter2", + }); + expect(identity).toMatchObject({ subject: "bob" }); + expect(directory.binds).toEqual(["cn=service", dn]); + await expect( + verify(s, String(id), { username: "bob", password: "wrong" }), + ).rejects.toMatchObject({ status: 401 }); + }, + ); + it("binds as the service, then as the user, and returns the identity", async () => { const { s, id } = await withDirectory(); directory.adminDns = ["uid=bob,ou=people"];