From a8b2bdd181548debb47bfb08f5bdfe2b65badf7e Mon Sep 17 00:00:00 2001 From: Kwangjin Ko Date: Wed, 1 Apr 2026 14:19:40 +0000 Subject: [PATCH 1/6] build: add ldapts and passport-custom dependencies Add ldapts (v8.1.7) for modern LDAP client support and passport-custom (v1.1.1) for custom Passport strategy creation. Signed-off-by: Kwangjin Ko --- package-lock.json | 32 ++++++++++++++++++++++++++++++++ package.json | 2 ++ 2 files changed, 34 insertions(+) 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", From 4ec870713e15c5f1eae040208098d47a4e9bf4fd Mon Sep 17 00:00:00 2001 From: Kwangjin Ko Date: Wed, 1 Apr 2026 14:21:18 +0000 Subject: [PATCH 2/6] feat: add ldap authentication type to config schema Add LDAP auth type definition to config.schema.json and generated TypeScript types with LdapConfig interface. Signed-off-by: Kwangjin Ko --- config.schema.json | 84 ++++++++++++++++++++++++++++++++++ src/config/generated/config.ts | 50 +++++++++++++++++++- 2 files changed, 133 insertions(+), 1 deletion(-) 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/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'], }; From 2f496e33df2c87ac45b6000bad7f57f3f4d633a0 Mon Sep 17 00:00:00 2001 From: Kwangjin Ko Date: Tue, 7 Apr 2026 23:21:16 +0900 Subject: [PATCH 3/6] chore: add default ldap config to proxy.config.json Add disabled ldap authentication entry with sensible defaults for attribute mappings, and group settings. Signed-off-by: Kwangjin Ko --- proxy.config.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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, From ade173d8c959ab21dbb50f5510db8276e0727da4 Mon Sep 17 00:00:00 2001 From: Kwangjin Ko Date: Wed, 1 Apr 2026 14:22:33 +0000 Subject: [PATCH 4/6] feat: implement LDAP passport strategy using ldapts Add new LDAP authentication strategy that uses ldapts for LDAP operations and passport-custom for Passport integration. The authentication flow: 1. Bind with service account 2. Search for user entry 3. Check group memberships (user/admin) 4. Verify user password via user bind 5. Sync user profile to database Signed-off-by: Kwangjin Ko --- src/service/passport/ldap.ts | 277 +++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 src/service/passport/ldap.ts 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; +}; From cb6633f6a44816a4d1ed4593077a3fba5a049302 Mon Sep 17 00:00:00 2001 From: Kwangjin Ko Date: Wed, 1 Apr 2026 14:23:19 +0000 Subject: [PATCH 5/6] feat: register ldap strategy in passport and auth routes Add ldap module to passport strategy registry and include it in the list of username/password login strategies. Signed-off-by: Kwangjin Ko --- src/service/passport/index.ts | 2 ++ src/service/routes/auth.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) 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/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. From 735e84239a128ee44614770942a18bd1d0027067 Mon Sep 17 00:00:00 2001 From: Kwangjin Ko Date: Wed, 1 Apr 2026 14:24:29 +0000 Subject: [PATCH 6/6] test: add unit tests for LDAP authentication strategy Test cases cover: successful auth with admin/non-admin roles, user not found, user group rejection, invalid password, connection errors, multiple entries in search result, missing credentials, and escapeFilterValue with normal strings, LDAP injection attempts, and RFC 4515 special characters. Signed-off-by: Kwangjin Ko --- test/services/passport/testLdapAuth.test.ts | 402 ++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 test/services/passport/testLdapAuth.test.ts 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'); + }); +});