diff --git a/config.schema.json b/config.schema.json index c72543037..5b64b8cf6 100644 --- a/config.schema.json +++ b/config.schema.json @@ -505,6 +505,90 @@ }, "required": ["type", "enabled", "oidcConfig"] }, + { + "title": "LDAP Auth Config", + "description": "Configuration for generic LDAP authentication using ldapts.", + "properties": { + "type": { "type": "string", "const": "ldap" }, + "enabled": { "type": "boolean" }, + "ldapConfig": { + "type": "object", + "description": "LDAP connection and search configuration.", + "properties": { + "url": { + "type": "string", + "description": "LDAP server URL, e.g. `ldap://ldap.example.com` or `ldaps://ldap.example.com`." + }, + "bindDN": { + "type": "string", + "description": "DN of the service account used to search for users, e.g. `cn=admin,dc=example,dc=com`." + }, + "bindPassword": { + "type": "string", + "description": "Password for the service account." + }, + "searchBase": { + "type": "string", + "description": "Base DN for user searches, e.g. `ou=people,dc=example,dc=com`." + }, + "searchFilter": { + "type": "string", + "description": "LDAP search filter template. Use `{{username}}` as a placeholder for the login username. e.g. `(uid={{username}})`." + }, + "userGroupDN": { + "type": "string", + "description": "DN of the group a user must belong to in order to log in." + }, + "adminGroupDN": { + "type": "string", + "description": "DN of the admin group. Members of this group are granted admin privileges." + }, + "groupSearchBase": { + "type": "string", + "description": "Base DN for group membership searches. If omitted, each group's own DN (`userGroupDN` or `adminGroupDN`) is used as the search base." + }, + "groupSearchFilter": { + "type": "string", + "description": "LDAP filter for group membership checks. Use `{{dn}}` as a placeholder for the user's DN. Defaults to `(member={{dn}})`." + }, + "usernameAttribute": { + "type": "string", + "description": "LDAP attribute to use as the username. Defaults to `uid`." + }, + "emailAttribute": { + "type": "string", + "description": "LDAP attribute for the user's email. Defaults to `mail`." + }, + "displayNameAttribute": { + "type": "string", + "description": "LDAP attribute for the user's display name. Defaults to `cn`." + }, + "titleAttribute": { + "type": "string", + "description": "LDAP attribute for the user's title. Defaults to `title`." + }, + "starttls": { + "type": "boolean", + "description": "Use STARTTLS to upgrade an ldap:// connection to TLS. Defaults to false." + }, + "tlsOptions": { + "type": "object", + "description": "Node.js TLS options passed to the ldapts client (e.g. `rejectUnauthorized`, `ca`)." + } + }, + "required": [ + "url", + "bindDN", + "bindPassword", + "searchBase", + "searchFilter", + "userGroupDN", + "adminGroupDN" + ] + } + }, + "required": ["type", "enabled", "ldapConfig"] + }, { "title": "JWT Auth Config", "description": "Configuration for JWT authentication.", diff --git a/package-lock.json b/package-lock.json index ee592bc12..c9e02ae38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "history": "5.3.0", "isomorphic-git": "^1.36.3", "jsonwebtoken": "^9.0.3", + "ldapts": "^8.1.7", "load-plugin": "^6.0.3", "lodash": "^4.17.23", "lusca": "^1.7.0", @@ -45,6 +46,7 @@ "parse-diff": "^0.11.1", "passport": "^0.7.0", "passport-activedirectory": "^1.4.0", + "passport-custom": "^1.1.1", "passport-local": "^1.0.0", "perfect-scrollbar": "^1.5.6", "react": "^16.14.0", @@ -9972,6 +9974,18 @@ "node": ">=10.13.0" } }, + "node_modules/ldapts": { + "version": "8.1.7", + "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.1.7.tgz", + "integrity": "sha512-TJl6T92eIwMf/OJ0hDfKVa6ISwzo+lqCWCI5Mf//ARlKa3LKQZaSrme/H2rCRBhW0DZCQlrsV+fgoW5YHRNLUw==", + "license": "MIT", + "dependencies": { + "strict-event-emitter-types": "2.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/levn": { "version": "0.4.1", "dev": true, @@ -11535,6 +11549,18 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-custom": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz", + "integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/passport-local": { "version": "1.0.0", "dependencies": { @@ -13068,6 +13094,12 @@ "stream-chain": "^2.2.5" } }, + "node_modules/strict-event-emitter-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", + "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==", + "license": "ISC" + }, "node_modules/string_decoder": { "version": "1.3.0", "license": "MIT", diff --git a/package.json b/package.json index c10721372..0a8ca781a 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "history": "5.3.0", "isomorphic-git": "^1.36.3", "jsonwebtoken": "^9.0.3", + "ldapts": "^8.1.7", "load-plugin": "^6.0.3", "lodash": "^4.17.23", "lusca": "^1.7.0", @@ -124,6 +125,7 @@ "parse-diff": "^0.11.1", "passport": "^0.7.0", "passport-activedirectory": "^1.4.0", + "passport-custom": "^1.1.1", "passport-local": "^1.0.0", "perfect-scrollbar": "^1.5.6", "react": "^16.14.0", diff --git a/proxy.config.json b/proxy.config.json index 715c38f48..fa3b68ce3 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -52,6 +52,27 @@ "password": "" } }, + { + "type": "ldap", + "enabled": false, + "ldapConfig": { + "url": "", + "bindDN": "", + "bindPassword": "", + "searchBase": "", + "searchFilter": "", + "userGroupDN": "", + "adminGroupDN": "", + "groupSearchBase": "", + "groupSearchFilter": "(member={{dn}})", + "usernameAttribute": "uid", + "emailAttribute": "mail", + "displayNameAttribute": "cn", + "titleAttribute": "title", + "starttls": false, + "tlsOptions": {} + } + }, { "type": "openidconnect", "enabled": false, diff --git a/src/config/generated/config.ts b/src/config/generated/config.ts index 0a85e8e70..8d3388ac5 100644 --- a/src/config/generated/config.ts +++ b/src/config/generated/config.ts @@ -190,6 +190,10 @@ export interface AuthenticationElement { * Additional JWT configuration. */ jwtConfig?: JwtConfig; + /** + * LDAP connection and search configuration. + */ + ldapConfig?: LdapConfig; [property: string]: any; } @@ -253,9 +257,32 @@ export interface OidcConfig { [property: string]: any; } +/** + * LDAP connection and search configuration. + */ +export interface LdapConfig { + url: string; + bindDN: string; + bindPassword: string; + searchBase: string; + searchFilter: string; + userGroupDN: string; + adminGroupDN: string; + groupSearchBase?: string; + groupSearchFilter?: string; + usernameAttribute?: string; + emailAttribute?: string; + displayNameAttribute?: string; + titleAttribute?: string; + starttls?: boolean; + tlsOptions?: { [key: string]: any }; + [property: string]: any; +} + export enum AuthenticationElementType { ActiveDirectory = 'ActiveDirectory', Jwt = 'jwt', + Ldap = 'ldap', Local = 'local', Openidconnect = 'openidconnect', } @@ -811,6 +838,7 @@ const typeMap: any = { { json: 'userGroup', js: 'userGroup', typ: u(undefined, '') }, { json: 'oidcConfig', js: 'oidcConfig', typ: u(undefined, r('OidcConfig')) }, { json: 'jwtConfig', js: 'jwtConfig', typ: u(undefined, r('JwtConfig')) }, + { json: 'ldapConfig', js: 'ldapConfig', typ: u(undefined, r('LdapConfig')) }, ], 'any', ), @@ -844,6 +872,26 @@ const typeMap: any = { ], 'any', ), + LdapConfig: o( + [ + { json: 'url', js: 'url', typ: '' }, + { json: 'bindDN', js: 'bindDN', typ: '' }, + { json: 'bindPassword', js: 'bindPassword', typ: '' }, + { json: 'searchBase', js: 'searchBase', typ: '' }, + { json: 'searchFilter', js: 'searchFilter', typ: '' }, + { json: 'userGroupDN', js: 'userGroupDN', typ: '' }, + { json: 'adminGroupDN', js: 'adminGroupDN', typ: '' }, + { json: 'groupSearchBase', js: 'groupSearchBase', typ: u(undefined, '') }, + { json: 'groupSearchFilter', js: 'groupSearchFilter', typ: u(undefined, '') }, + { json: 'usernameAttribute', js: 'usernameAttribute', typ: u(undefined, '') }, + { json: 'emailAttribute', js: 'emailAttribute', typ: u(undefined, '') }, + { json: 'displayNameAttribute', js: 'displayNameAttribute', typ: u(undefined, '') }, + { json: 'titleAttribute', js: 'titleAttribute', typ: u(undefined, '') }, + { json: 'starttls', js: 'starttls', typ: u(undefined, true) }, + { json: 'tlsOptions', js: 'tlsOptions', typ: u(undefined, m('any')) }, + ], + 'any', + ), AttestationConfig: o( [{ json: 'questions', js: 'questions', typ: u(undefined, a(r('Question'))) }], false, @@ -981,6 +1029,6 @@ const typeMap: any = { ], 'any', ), - AuthenticationElementType: ['ActiveDirectory', 'jwt', 'local', 'openidconnect'], + AuthenticationElementType: ['ActiveDirectory', 'jwt', 'ldap', 'local', 'openidconnect'], DatabaseType: ['fs', 'mongo'], }; diff --git a/src/service/passport/index.ts b/src/service/passport/index.ts index 1bfeca6d7..63d26e558 100644 --- a/src/service/passport/index.ts +++ b/src/service/passport/index.ts @@ -17,6 +17,7 @@ import passport, { type PassportStatic } from 'passport'; import * as local from './local'; import * as activeDirectory from './activeDirectory'; +import * as ldap from './ldap'; import * as oidc from './oidc'; import * as config from '../../config'; import { AuthenticationElement } from '../../config/generated/config'; @@ -30,6 +31,7 @@ type StrategyModule = { export const authStrategies: Record = { local, activedirectory: activeDirectory, + ldap, openidconnect: oidc, }; diff --git a/src/service/passport/ldap.ts b/src/service/passport/ldap.ts new file mode 100644 index 000000000..534be0a66 --- /dev/null +++ b/src/service/passport/ldap.ts @@ -0,0 +1,277 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Client } from 'ldapts'; +import { Strategy as CustomStrategy } from 'passport-custom'; +import type { PassportStatic } from 'passport'; +import type { Request } from 'express'; + +import * as db from '../../db'; +import { getAuthMethods } from '../../config'; +import { LdapConfig } from '../../config/generated/config'; +import { handleErrorAndLog } from '../../utils/errors'; + +export const type = 'ldap'; + +/** + * Escape special characters in LDAP filter values per RFC 4515. + */ +export const escapeFilterValue = (value: string): string => { + let result = ''; + for (const ch of value) { + const code = ch.charCodeAt(0); + if (code === 0 || '\\*()|&!=<>~'.includes(ch)) { + result += '\\' + code.toString(16).padStart(2, '0'); + } else { + result += ch; + } + } + return result; +}; + +const getLdapConfig = (): LdapConfig => { + const authMethods = getAuthMethods(); + const config = authMethods.find((method) => method.type.toLowerCase() === type); + + if (!config || !config.ldapConfig) { + throw new Error('LDAP authentication method not enabled or missing ldapConfig'); + } + + const lc = config.ldapConfig; + const requiredFields = [ + 'url', + 'bindDN', + 'bindPassword', + 'searchBase', + 'searchFilter', + 'userGroupDN', + 'adminGroupDN', + ] as const; + for (const field of requiredFields) { + if (!lc[field]) { + throw new Error(`LDAP configuration field "${field}" is required but empty`); + } + } + + return lc; +}; + +const createClient = (ldapConfig: LdapConfig): Client => { + return new Client({ + url: ldapConfig.url, + tlsOptions: ldapConfig.tlsOptions, + strictDN: true, + }); +}; + +/** + * Search for a user entry in LDAP using the service account. + */ +export const searchUser = async ( + client: Client, + ldapConfig: LdapConfig, + username: string, +): Promise | null> => { + const filter = ldapConfig.searchFilter.replaceAll('{{username}}', escapeFilterValue(username)); + + const { searchEntries } = await client.search(ldapConfig.searchBase, { + scope: 'sub', + filter, + }); + + if (searchEntries.length === 0) { + return null; + } + + if (searchEntries.length > 1) { + console.warn( + `ldap: search filter matched ${searchEntries.length} entries for username "${username}", expected exactly 1`, + ); + return null; + } + + return searchEntries[0] as Record; +}; + +/** + * Check if a user is a member of a specific group by searching for a group + * entry that references the user's DN. + */ +export const isUserInGroup = async ( + client: Client, + ldapConfig: LdapConfig, + userDN: string, + groupDN: string, +): Promise => { + const groupFilter = (ldapConfig.groupSearchFilter || '(member={{dn}})').replaceAll( + '{{dn}}', + escapeFilterValue(userDN), + ); + + const searchBase = ldapConfig.groupSearchBase || groupDN; + + try { + const { searchEntries } = await client.search(searchBase, { + scope: 'sub', + filter: `(&(objectClass=*)${groupFilter})`, + }); + + return searchEntries.some( + (entry) => typeof entry.dn === 'string' && entry.dn.toLowerCase() === groupDN.toLowerCase(), + ); + } catch { + return false; + } +}; + +/** + * Verify user credentials via user bind (separate connection). + */ +const verifyPassword = async ( + ldapConfig: LdapConfig, + userDN: string, + password: string, +): Promise => { + const userClient = createClient(ldapConfig); + try { + if (ldapConfig.starttls) { + await userClient.startTLS(ldapConfig.tlsOptions || {}); + } + await userClient.bind(userDN, password); + return true; + } catch { + return false; + } finally { + await userClient.unbind(); + } +}; + +/** + * Authenticate a user against LDAP. Returns the user object on success, or null on failure. + * Throws on unexpected errors (e.g. connection failure). + */ +export const authenticateUser = async ( + ldapConfig: LdapConfig, + username: string, + password: string, +): Promise | null> => { + const usernameAttr = ldapConfig.usernameAttribute || 'uid'; + const emailAttr = ldapConfig.emailAttribute || 'mail'; + const displayNameAttr = ldapConfig.displayNameAttribute || 'cn'; + const titleAttr = ldapConfig.titleAttribute || 'title'; + + const client = createClient(ldapConfig); + + try { + // Step 1: STARTTLS upgrade if configured + if (ldapConfig.starttls) { + await client.startTLS(ldapConfig.tlsOptions || {}); + } + + // Step 2: Bind with service account to search for the user + await client.bind(ldapConfig.bindDN, ldapConfig.bindPassword); + + // Step 3: Search for the user entry + const entry = await searchUser(client, ldapConfig, username); + if (!entry) { + return null; + } + + const userDN = entry.dn as string; + + // Step 4: Check user group membership + const isMember = await isUserInGroup(client, ldapConfig, userDN, ldapConfig.userGroupDN); + if (!isMember) { + console.log(`ldap: user ${username} is not a member of ${ldapConfig.userGroupDN}`); + return null; + } + + // Step 5: Check admin group membership + let isAdmin = false; + try { + isAdmin = await isUserInGroup(client, ldapConfig, userDN, ldapConfig.adminGroupDN); + } catch (error: unknown) { + handleErrorAndLog(error, 'Error checking admin group membership'); + } + + // Step 6: Unbind service account and verify user's password + await client.unbind(); + + const passwordValid = await verifyPassword(ldapConfig, userDN, password); + if (!passwordValid) { + return null; + } + + // Step 7: Extract profile attributes and sync to database + const userObj = { + username: String(entry[usernameAttr] || username).toLowerCase(), + email: String(entry[emailAttr] || '').toLowerCase(), + admin: isAdmin, + displayName: String(entry[displayNameAttr] || ''), + title: String(entry[titleAttr] || ''), + }; + + console.log(`ldap: authenticated ${userObj.username}, admin=${isAdmin}`); + + await db.updateUser(userObj); + + return userObj; + } finally { + try { + await client.unbind(); + } catch { + // ignore unbind errors on cleanup + } + } +}; + +export const configure = async (passport: PassportStatic): Promise => { + const ldapConfig = getLdapConfig(); + + passport.use( + type, + new CustomStrategy(async (req: Request, done) => { + const { username, password } = req.body; + + if (!username || !password) { + return done(null, false); + } + + try { + const user = await authenticateUser(ldapConfig, username, password); + return done(null, user || false); + } catch (error: unknown) { + const message = handleErrorAndLog(error, 'LDAP authentication error'); + return done(message); + } + }), + ); + + passport.serializeUser((user: Partial, done) => { + done(null, user.username); + }); + + passport.deserializeUser(async (username: string, done) => { + try { + const user = await db.findUser(username); + done(null, user); + } catch (error: unknown) { + done(error, null); + } + }); + + return passport; +}; diff --git a/src/service/routes/auth.ts b/src/service/routes/auth.ts index a03c80480..f621a586b 100644 --- a/src/service/routes/auth.ts +++ b/src/service/routes/auth.ts @@ -21,6 +21,7 @@ import { getAuthMethods } from '../../config'; import * as db from '../../db'; import * as passportLocal from '../passport/local'; import * as passportAD from '../passport/activeDirectory'; +import * as passportLdap from '../passport/ldap'; import { User } from '../../db/types'; import { AuthenticationElement } from '../../config/generated/config'; @@ -52,7 +53,7 @@ router.get('/', (_req: Request, res: Response) => { }); // login strategies that will work with /login e.g. take username and password -const appropriateLoginStrategies = [passportLocal.type, passportAD.type]; +const appropriateLoginStrategies = [passportLocal.type, passportAD.type, passportLdap.type]; // getLoginStrategy fetches the enabled auth methods and identifies if there's an appropriate // auth method for username and password login. If there isn't it returns null, if there is it // returns the first. diff --git a/test/services/passport/testLdapAuth.test.ts b/test/services/passport/testLdapAuth.test.ts new file mode 100644 index 000000000..697320b62 --- /dev/null +++ b/test/services/passport/testLdapAuth.test.ts @@ -0,0 +1,402 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, beforeEach, expect, vi, type Mock, afterEach } from 'vitest'; + +let dbStub: { updateUser: Mock; findUser: Mock }; +let passportStub: { + use: Mock; + serializeUser: Mock; + deserializeUser: Mock; +}; + +// The callback captured from passport.use(type, new CustomStrategy(callback)) +let strategyCallback: (req: any, done: (err: unknown, user?: unknown) => void) => Promise; + +// Mock ldapts Client instances +let serviceClientMock: { + bind: Mock; + unbind: Mock; + search: Mock; + startTLS: Mock; +}; +let userClientMock: { + bind: Mock; + unbind: Mock; + startTLS: Mock; +}; +let clientInstances: any[]; + +const ldapConfig = { + url: 'ldap://test-ldap:389', + bindDN: 'cn=admin,dc=test,dc=com', + bindPassword: 'admin-password', + searchBase: 'ou=people,dc=test,dc=com', + searchFilter: '(uid={{username}})', + userGroupDN: 'cn=users,ou=groups,dc=test,dc=com', + adminGroupDN: 'cn=admins,ou=groups,dc=test,dc=com', + groupSearchBase: 'ou=groups,dc=test,dc=com', + groupSearchFilter: '(member={{dn}})', + usernameAttribute: 'uid', + emailAttribute: 'mail', + displayNameAttribute: 'cn', + titleAttribute: 'title', + starttls: false, + tlsOptions: {}, +}; + +const newConfig = JSON.stringify({ + authentication: [ + { + type: 'ldap', + enabled: true, + ldapConfig, + }, + ], +}); + +const createClientMock = () => ({ + bind: vi.fn().mockResolvedValue(undefined), + unbind: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue({ searchEntries: [], searchReferences: [] }), + startTLS: vi.fn().mockResolvedValue(undefined), +}); + +describe('LDAP auth method', () => { + beforeEach(async () => { + dbStub = { + updateUser: vi.fn().mockResolvedValue(undefined), + findUser: vi.fn().mockResolvedValue(null), + }; + + passportStub = { + use: vi.fn(), + serializeUser: vi.fn(), + deserializeUser: vi.fn(), + }; + + clientInstances = []; + serviceClientMock = createClientMock(); + userClientMock = createClientMock(); + + // Track which instance is created + let callCount = 0; + + // mock fs for config + vi.doMock('fs', (importOriginal) => { + const actual = importOriginal(); + return { + ...actual, + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue(newConfig), + }; + }); + + vi.doMock('../../../src/db', () => dbStub); + + vi.doMock('ldapts', () => ({ + Client: function (opts: any) { + const mock = callCount === 0 ? serviceClientMock : userClientMock; + callCount++; + clientInstances.push(mock); + return mock; + }, + })); + + vi.doMock('passport-custom', () => ({ + Strategy: function (callback: any) { + strategyCallback = callback; + return { name: 'ldap', authenticate: () => {} }; + }, + })); + + // First import config + const config = await import('../../../src/config/index.js'); + config.initUserConfig(); + vi.doMock('../../../src/config', () => config); + + // then configure ldap + const { configure } = await import('../../../src/service/passport/ldap.js'); + await configure(passportStub as any); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it('should authenticate a valid user and mark them as admin', async () => { + // Service account search returns a user entry + serviceClientMock.search + .mockResolvedValueOnce({ + searchEntries: [ + { + dn: 'uid=testuser,ou=people,dc=test,dc=com', + uid: 'testuser', + mail: 'test@test.com', + cn: 'Test User', + title: 'Engineer', + }, + ], + }) + // userGroup membership check + .mockResolvedValueOnce({ + searchEntries: [{ dn: 'cn=users,ou=groups,dc=test,dc=com' }], + }) + // adminGroup membership check + .mockResolvedValueOnce({ + searchEntries: [{ dn: 'cn=admins,ou=groups,dc=test,dc=com' }], + }); + + // User bind succeeds (valid password) + userClientMock.bind.mockResolvedValueOnce(undefined); + + const req = { body: { username: 'testuser', password: 'secret' } }; + const done = vi.fn(); + + await strategyCallback(req, done); + + expect(done).toHaveBeenCalledOnce(); + const [err, user] = done.mock.calls[0]; + expect(err).toBeNull(); + expect(user).toMatchObject({ + username: 'testuser', + email: 'test@test.com', + displayName: 'Test User', + admin: true, + title: 'Engineer', + }); + + expect(dbStub.updateUser).toHaveBeenCalledOnce(); + }); + + it('should authenticate a non-admin user', async () => { + serviceClientMock.search + .mockResolvedValueOnce({ + searchEntries: [ + { + dn: 'uid=regular,ou=people,dc=test,dc=com', + uid: 'regular', + mail: 'regular@test.com', + cn: 'Regular User', + title: 'Developer', + }, + ], + }) + // userGroup membership check - is member + .mockResolvedValueOnce({ + searchEntries: [{ dn: 'cn=users,ou=groups,dc=test,dc=com' }], + }) + // adminGroup membership check - not member + .mockResolvedValueOnce({ + searchEntries: [], + }); + + userClientMock.bind.mockResolvedValueOnce(undefined); + + const req = { body: { username: 'regular', password: 'pass' } }; + const done = vi.fn(); + + await strategyCallback(req, done); + + expect(done).toHaveBeenCalledOnce(); + const [err, user] = done.mock.calls[0]; + expect(err).toBeNull(); + expect(user).toMatchObject({ + username: 'regular', + admin: false, + }); + }); + + it('should fail if user is not found in LDAP', async () => { + serviceClientMock.search.mockResolvedValueOnce({ + searchEntries: [], + }); + + const req = { body: { username: 'nouser', password: 'pass' } }; + const done = vi.fn(); + + await strategyCallback(req, done); + + expect(done).toHaveBeenCalledOnce(); + const [err, user] = done.mock.calls[0]; + expect(err).toBeNull(); + expect(user).toBe(false); + expect(dbStub.updateUser).not.toHaveBeenCalled(); + }); + + it('should fail if user is not in user group', async () => { + serviceClientMock.search + .mockResolvedValueOnce({ + searchEntries: [ + { + dn: 'uid=outsider,ou=people,dc=test,dc=com', + uid: 'outsider', + mail: 'out@test.com', + cn: 'Outsider', + title: '', + }, + ], + }) + // userGroup membership check - not a member + .mockResolvedValueOnce({ + searchEntries: [], + }); + + const req = { body: { username: 'outsider', password: 'pass' } }; + const done = vi.fn(); + + await strategyCallback(req, done); + + expect(done).toHaveBeenCalledOnce(); + const [err, user] = done.mock.calls[0]; + expect(err).toBeNull(); + expect(user).toBe(false); + expect(dbStub.updateUser).not.toHaveBeenCalled(); + }); + + it('should fail if user password is incorrect (user bind fails)', async () => { + serviceClientMock.search + .mockResolvedValueOnce({ + searchEntries: [ + { + dn: 'uid=testuser,ou=people,dc=test,dc=com', + uid: 'testuser', + mail: 'test@test.com', + cn: 'Test User', + title: '', + }, + ], + }) + // userGroup membership check + .mockResolvedValueOnce({ + searchEntries: [{ dn: 'cn=users,ou=groups,dc=test,dc=com' }], + }) + // adminGroup membership check + .mockResolvedValueOnce({ + searchEntries: [], + }); + + // User bind fails - wrong password + userClientMock.bind.mockRejectedValueOnce(new Error('Invalid credentials')); + + const req = { body: { username: 'testuser', password: 'wrong' } }; + const done = vi.fn(); + + await strategyCallback(req, done); + + expect(done).toHaveBeenCalledOnce(); + const [err, user] = done.mock.calls[0]; + expect(err).toBeNull(); + expect(user).toBe(false); + expect(dbStub.updateUser).not.toHaveBeenCalled(); + }); + + it('should handle LDAP connection errors gracefully', async () => { + serviceClientMock.bind.mockRejectedValueOnce(new Error('Connection refused')); + + const req = { body: { username: 'testuser', password: 'pass' } }; + const done = vi.fn(); + + await strategyCallback(req, done); + + expect(done).toHaveBeenCalledOnce(); + const [err] = done.mock.calls[0]; + expect(err).toBeTruthy(); + expect(dbStub.updateUser).not.toHaveBeenCalled(); + }); + + it('should fail if search returns multiple entries', async () => { + serviceClientMock.search.mockResolvedValueOnce({ + searchEntries: [ + { dn: 'uid=user1,ou=people,dc=test,dc=com', uid: 'user1' }, + { dn: 'uid=user2,ou=people,dc=test,dc=com', uid: 'user2' }, + ], + }); + + const req = { body: { username: 'user1', password: 'pass' } }; + const done = vi.fn(); + + await strategyCallback(req, done); + + expect(done).toHaveBeenCalledOnce(); + const [err, user] = done.mock.calls[0]; + expect(err).toBeNull(); + expect(user).toBe(false); + expect(dbStub.updateUser).not.toHaveBeenCalled(); + }); + + it('should fail when username or password is missing', async () => { + const done = vi.fn(); + + await strategyCallback({ body: { username: '', password: 'pass' } }, done); + expect(done).toHaveBeenCalledWith(null, false); + + done.mockClear(); + + await strategyCallback({ body: { username: 'user', password: '' } }, done); + expect(done).toHaveBeenCalledWith(null, false); + }); +}); + +describe('escapeFilterValue', () => { + let escapeFilterValue: (value: string) => string; + + beforeEach(async () => { + vi.resetModules(); + // Import directly without configuring passport (no config mocks needed) + const mod = await import('../../../src/service/passport/ldap.js'); + escapeFilterValue = mod.escapeFilterValue; + }); + + afterEach(() => { + vi.resetModules(); + }); + + it('should return normal strings unchanged', () => { + expect(escapeFilterValue('testuser')).toBe('testuser'); + expect(escapeFilterValue('john.doe')).toBe('john.doe'); + expect(escapeFilterValue('')).toBe(''); + }); + + it('should escape LDAP injection attempts', () => { + // Classic injection: close filter and add wildcard match + const injected = escapeFilterValue('admin)(|(uid=*'); + expect(injected).not.toContain('('); + expect(injected).not.toContain(')'); + expect(injected).not.toContain('*'); + }); + + it('should escape all RFC 4515 special characters', () => { + expect(escapeFilterValue('*')).toBe('\\2a'); + expect(escapeFilterValue('(')).toBe('\\28'); + expect(escapeFilterValue(')')).toBe('\\29'); + expect(escapeFilterValue('\\')).toBe('\\5c'); + expect(escapeFilterValue('\0')).toBe('\\00'); + expect(escapeFilterValue('|')).toBe('\\7c'); + expect(escapeFilterValue('&')).toBe('\\26'); + expect(escapeFilterValue('=')).toBe('\\3d'); + expect(escapeFilterValue('!')).toBe('\\21'); + expect(escapeFilterValue('<')).toBe('\\3c'); + expect(escapeFilterValue('>')).toBe('\\3e'); + expect(escapeFilterValue('~')).toBe('\\7e'); + }); + + it('should escape special characters within a string', () => { + expect(escapeFilterValue('user*name')).toBe('user\\2aname'); + expect(escapeFilterValue('a(b)c')).toBe('a\\28b\\29c'); + }); +});