import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { startServer, type TestServer } from "./helpers.js"; const directory = vi.hoisted(() => ({ users: {} as Record< string, { dn: string; password: string; attrs: Record } >, adminDns: [] as string[], servicePassword: "svc", binds: [] as string[], })); vi.mock("ldapjs", async () => { const { default: ldap } = await vi.importActual("ldapjs"); function createClient() { return { bind: (dn: string, password: string, cb: (err?: Error) => void) => { directory.binds.push(dn); if (dn === "cn=service" && password === directory.servicePassword) { return cb(); } const user = Object.values(directory.users).find((u) => u.dn === dn); if (user && user.password === password) return cb(); cb(new Error("invalid credentials")); }, search: ( base: string, options: { filter: string; attributes: string[] }, cb: (err: Error | null, res: unknown) => void, ) => { const listeners: Record void> = {}; cb(null, { on: (event: string, fn: (value?: unknown) => void) => { listeners[event] = fn; }, }); queueMicrotask(() => { if (base === "ou=groups") { for (const dn of directory.adminDns) { if (options.filter.includes(dn)) { listeners.searchEntry?.({ dn: { toString: () => "cn=admins,ou=groups" }, attributes: [{ type: "cn", values: ["admins"] }], }); } } } else { const match = /uid=([^)]+)/.exec(options.filter); const user = match ? directory.users[match[1]] : undefined; if (user) { listeners.searchEntry?.({ dn: ldap.parseDN(user.dn), attributes: Object.entries(user.attrs) .filter(([type]) => options.attributes.includes(type)) .map(([type, value]) => ({ type, values: [value], })), }); } } listeners.end?.(); }); }, unbind: () => {}, }; } return { default: { createClient }, createClient }; }); const CONFIG = { host: "ldap.example", port: 389, bindDN: "cn=service", bindPassword: "svc", userSearchBase: "ou=people", userSearchFilter: "(uid={{username}})", usernameAttribute: "uid", displayNameAttribute: "cn", groupSearchBase: "ou=groups", adminGroup: "admins", allowedUsers: "@example.com", }; let server: TestServer | null = null; beforeEach(() => { directory.users = { bob: { dn: "uid=bob,ou=people", password: "hunter2", attrs: { uid: "bob", cn: "Bob Builder", mail: "bob@example.com" }, }, }; directory.adminDns = []; directory.binds = []; }); afterEach(async () => { await server?.close(); server = null; }); async function withDirectory(options: Parameters[0] = {}) { server = await startServer(options); const created = await server.request("POST", "/providers", { body: { name: "Corp LDAP", config: CONFIG }, }); expect(created.status).toBe(201); return { s: server, id: created.body.id as number }; } function verify( s: TestServer, instance: string, body: Record, ) { const method = s.mock.auth.loginMethods[0]; return method.verify!( { body, query: {}, headers: {}, ip: "10.0.0.1" }, instance, ); } 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"]; const identity = await verify(s, String(id), { username: "bob", password: "hunter2", rememberMe: true, }); expect(identity).toEqual({ kind: "external", provider: `ldap:${id}`, subject: "bob", email: "bob@example.com", name: "Bob Builder", isAdmin: true, allowedUsers: "@example.com", legacy: { identifier: `ldap:${id}:bob`, providerRowId: id }, rememberMe: true, rateLimitKey: `${id}:bob`, }); expect(directory.binds).toEqual(["cn=service", "uid=bob,ou=people"]); }); it("is not an admin when the directory says so", async () => { const { s, id } = await withDirectory(); const identity = await verify(s, String(id), { username: "bob", password: "hunter2", }); expect(identity).toMatchObject({ isAdmin: false }); }); it("refuses a wrong password and an unknown user the same way", async () => { const { s, id } = await withDirectory(); await expect( verify(s, String(id), { username: "bob", password: "nope" }), ).rejects.toMatchObject({ status: 401, message: "Invalid username or password", }); await expect( verify(s, String(id), { username: "mallory", password: "x" }), ).rejects.toMatchObject({ status: 401, message: "Invalid username or password", }); expect(s.mock.auth.loginFailures.get(`10.0.0.1|${id}:bob`)).toBe(1); }); it("locks out after too many failures, like password login", async () => { const { s, id } = await withDirectory({ loginAttemptLimit: 2 }); for (let i = 0; i < 2; i++) { await expect( verify(s, String(id), { username: "bob", password: "nope" }), ).rejects.toMatchObject({ status: 401 }); } await expect( verify(s, String(id), { username: "bob", password: "hunter2" }), ).rejects.toMatchObject({ status: 429 }); }); it("escapes the username in the search filter", async () => { const { s, id } = await withDirectory(); await expect( verify(s, String(id), { username: "bob)(uid=*", password: "hunter2" }), ).rejects.toMatchObject({ status: 401 }); }); it("refuses a disabled or unknown directory", async () => { const { s, id } = await withDirectory(); await s.request("PUT", `/providers/${id}`, { body: { enabled: false } }); await expect( verify(s, String(id), { username: "bob", password: "hunter2" }), ).rejects.toMatchObject({ status: 404 }); await expect( verify(s, "999", { username: "bob", password: "hunter2" }), ).rejects.toMatchObject({ status: 404 }); expect(await s.mock.auth.loginMethods[0].describe!()).toEqual([ { id: String(id), label: "Corp LDAP", enabled: false, type: "ldap" }, ]); }); it("needs a directory, a username and a password", async () => { const { s } = await withDirectory(); await expect( verify(s, null as never, { username: "bob" }), ).rejects.toMatchObject({ status: 400 }); }); }); describe("directory admin routes", () => { it("seal the bind password and never send it back", async () => { const { s, id } = await withDirectory(); const stored = s.db.sqlite .prepare("SELECT config FROM p_ldap_providers WHERE id = ?") .get(id) as { config: string }; expect(JSON.parse(stored.config).bindPassword).toMatch(/^sealed:/); const listed = await s.request("GET", "/providers"); expect(listed.body.providers[0]).toMatchObject({ name: "Corp LDAP", hasBindPassword: true, }); expect(listed.body.providers[0].config.bindPassword).toBeUndefined(); }); it("keep the bind password when an edit leaves it empty", async () => { const { s, id } = await withDirectory(); const updated = await s.request("PUT", `/providers/${id}`, { body: { config: { host: "ldap2.example", bindPassword: "" } }, }); expect(updated.body).toMatchObject({ hasBindPassword: true, config: { host: "ldap2.example" }, }); await expect( verify(s, String(id), { username: "bob", password: "hunter2" }), ).resolves.toMatchObject({ subject: "bob" }); }); it("reject a directory with missing fields", async () => { server = await startServer(); const response = await server.request("POST", "/providers", { body: { name: "X", config: { host: "a" } }, }); expect(response.status).toBe(400); }); it("will not delete a directory people still sign in with", async () => { const { s, id } = await withDirectory({ linkedUsers: { "ldap:1": 1 }, }); expect(id).toBe(1); expect((await s.request("DELETE", `/providers/${id}`)).status).toBe(409); }); it("delete a directory nobody uses", async () => { const { s, id } = await withDirectory(); expect((await s.request("DELETE", `/providers/${id}`)).status).toBe(200); expect(await s.mock.auth.loginMethods[0].describe!()).toEqual([]); }); it("need ldap.manage", async () => { server = await startServer({ permissions: [] }); for (const [method, path] of [ ["GET", "/providers"], ["POST", "/providers"], ["PUT", "/providers/1"], ["DELETE", "/providers/1"], ]) { expect((await server.request(method, path, { body: {} })).status).toBe( 403, ); } }); }); describe("capabilities", () => { it.each(["db:own", "auth:provide", "network:serve"])( "activate fails closed without %s", async (capability) => { const { manifest } = await import("./helpers.js"); await expect( startServer({ capabilities: manifest.capabilities.filter((c) => c !== capability), }), ).rejects.toThrow(); }, ); it("cannot read a bind password without secrets:own", async () => { const { manifest } = await import("./helpers.js"); server = await startServer({ capabilities: manifest.capabilities.filter((c) => c !== "secrets:own"), }); const response = await server.request("POST", "/providers", { body: { name: "Corp LDAP", config: CONFIG }, }); expect(response.status).toBe(500); }); });