Skip to content

Commit ade173d

Browse files
committed
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 <kyet@me.com>
1 parent 2f496e3 commit ade173d

1 file changed

Lines changed: 277 additions & 0 deletions

File tree

src/service/passport/ldap.ts

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Client } from 'ldapts';
18+
import { Strategy as CustomStrategy } from 'passport-custom';
19+
import type { PassportStatic } from 'passport';
20+
import type { Request } from 'express';
21+
22+
import * as db from '../../db';
23+
import { getAuthMethods } from '../../config';
24+
import { LdapConfig } from '../../config/generated/config';
25+
import { handleErrorAndLog } from '../../utils/errors';
26+
27+
export const type = 'ldap';
28+
29+
/**
30+
* Escape special characters in LDAP filter values per RFC 4515.
31+
*/
32+
export const escapeFilterValue = (value: string): string => {
33+
let result = '';
34+
for (const ch of value) {
35+
const code = ch.charCodeAt(0);
36+
if (code === 0 || '\\*()|&!=<>~'.includes(ch)) {
37+
result += '\\' + code.toString(16).padStart(2, '0');
38+
} else {
39+
result += ch;
40+
}
41+
}
42+
return result;
43+
};
44+
45+
const getLdapConfig = (): LdapConfig => {
46+
const authMethods = getAuthMethods();
47+
const config = authMethods.find((method) => method.type.toLowerCase() === type);
48+
49+
if (!config || !config.ldapConfig) {
50+
throw new Error('LDAP authentication method not enabled or missing ldapConfig');
51+
}
52+
53+
const lc = config.ldapConfig;
54+
const requiredFields = [
55+
'url',
56+
'bindDN',
57+
'bindPassword',
58+
'searchBase',
59+
'searchFilter',
60+
'userGroupDN',
61+
'adminGroupDN',
62+
] as const;
63+
for (const field of requiredFields) {
64+
if (!lc[field]) {
65+
throw new Error(`LDAP configuration field "${field}" is required but empty`);
66+
}
67+
}
68+
69+
return lc;
70+
};
71+
72+
const createClient = (ldapConfig: LdapConfig): Client => {
73+
return new Client({
74+
url: ldapConfig.url,
75+
tlsOptions: ldapConfig.tlsOptions,
76+
strictDN: true,
77+
});
78+
};
79+
80+
/**
81+
* Search for a user entry in LDAP using the service account.
82+
*/
83+
export const searchUser = async (
84+
client: Client,
85+
ldapConfig: LdapConfig,
86+
username: string,
87+
): Promise<Record<string, unknown> | null> => {
88+
const filter = ldapConfig.searchFilter.replaceAll('{{username}}', escapeFilterValue(username));
89+
90+
const { searchEntries } = await client.search(ldapConfig.searchBase, {
91+
scope: 'sub',
92+
filter,
93+
});
94+
95+
if (searchEntries.length === 0) {
96+
return null;
97+
}
98+
99+
if (searchEntries.length > 1) {
100+
console.warn(
101+
`ldap: search filter matched ${searchEntries.length} entries for username "${username}", expected exactly 1`,
102+
);
103+
return null;
104+
}
105+
106+
return searchEntries[0] as Record<string, unknown>;
107+
};
108+
109+
/**
110+
* Check if a user is a member of a specific group by searching for a group
111+
* entry that references the user's DN.
112+
*/
113+
export const isUserInGroup = async (
114+
client: Client,
115+
ldapConfig: LdapConfig,
116+
userDN: string,
117+
groupDN: string,
118+
): Promise<boolean> => {
119+
const groupFilter = (ldapConfig.groupSearchFilter || '(member={{dn}})').replaceAll(
120+
'{{dn}}',
121+
escapeFilterValue(userDN),
122+
);
123+
124+
const searchBase = ldapConfig.groupSearchBase || groupDN;
125+
126+
try {
127+
const { searchEntries } = await client.search(searchBase, {
128+
scope: 'sub',
129+
filter: `(&(objectClass=*)${groupFilter})`,
130+
});
131+
132+
return searchEntries.some(
133+
(entry) => typeof entry.dn === 'string' && entry.dn.toLowerCase() === groupDN.toLowerCase(),
134+
);
135+
} catch {
136+
return false;
137+
}
138+
};
139+
140+
/**
141+
* Verify user credentials via user bind (separate connection).
142+
*/
143+
const verifyPassword = async (
144+
ldapConfig: LdapConfig,
145+
userDN: string,
146+
password: string,
147+
): Promise<boolean> => {
148+
const userClient = createClient(ldapConfig);
149+
try {
150+
if (ldapConfig.starttls) {
151+
await userClient.startTLS(ldapConfig.tlsOptions || {});
152+
}
153+
await userClient.bind(userDN, password);
154+
return true;
155+
} catch {
156+
return false;
157+
} finally {
158+
await userClient.unbind();
159+
}
160+
};
161+
162+
/**
163+
* Authenticate a user against LDAP. Returns the user object on success, or null on failure.
164+
* Throws on unexpected errors (e.g. connection failure).
165+
*/
166+
export const authenticateUser = async (
167+
ldapConfig: LdapConfig,
168+
username: string,
169+
password: string,
170+
): Promise<Partial<db.User> | null> => {
171+
const usernameAttr = ldapConfig.usernameAttribute || 'uid';
172+
const emailAttr = ldapConfig.emailAttribute || 'mail';
173+
const displayNameAttr = ldapConfig.displayNameAttribute || 'cn';
174+
const titleAttr = ldapConfig.titleAttribute || 'title';
175+
176+
const client = createClient(ldapConfig);
177+
178+
try {
179+
// Step 1: STARTTLS upgrade if configured
180+
if (ldapConfig.starttls) {
181+
await client.startTLS(ldapConfig.tlsOptions || {});
182+
}
183+
184+
// Step 2: Bind with service account to search for the user
185+
await client.bind(ldapConfig.bindDN, ldapConfig.bindPassword);
186+
187+
// Step 3: Search for the user entry
188+
const entry = await searchUser(client, ldapConfig, username);
189+
if (!entry) {
190+
return null;
191+
}
192+
193+
const userDN = entry.dn as string;
194+
195+
// Step 4: Check user group membership
196+
const isMember = await isUserInGroup(client, ldapConfig, userDN, ldapConfig.userGroupDN);
197+
if (!isMember) {
198+
console.log(`ldap: user ${username} is not a member of ${ldapConfig.userGroupDN}`);
199+
return null;
200+
}
201+
202+
// Step 5: Check admin group membership
203+
let isAdmin = false;
204+
try {
205+
isAdmin = await isUserInGroup(client, ldapConfig, userDN, ldapConfig.adminGroupDN);
206+
} catch (error: unknown) {
207+
handleErrorAndLog(error, 'Error checking admin group membership');
208+
}
209+
210+
// Step 6: Unbind service account and verify user's password
211+
await client.unbind();
212+
213+
const passwordValid = await verifyPassword(ldapConfig, userDN, password);
214+
if (!passwordValid) {
215+
return null;
216+
}
217+
218+
// Step 7: Extract profile attributes and sync to database
219+
const userObj = {
220+
username: String(entry[usernameAttr] || username).toLowerCase(),
221+
email: String(entry[emailAttr] || '').toLowerCase(),
222+
admin: isAdmin,
223+
displayName: String(entry[displayNameAttr] || ''),
224+
title: String(entry[titleAttr] || ''),
225+
};
226+
227+
console.log(`ldap: authenticated ${userObj.username}, admin=${isAdmin}`);
228+
229+
await db.updateUser(userObj);
230+
231+
return userObj;
232+
} finally {
233+
try {
234+
await client.unbind();
235+
} catch {
236+
// ignore unbind errors on cleanup
237+
}
238+
}
239+
};
240+
241+
export const configure = async (passport: PassportStatic): Promise<PassportStatic> => {
242+
const ldapConfig = getLdapConfig();
243+
244+
passport.use(
245+
type,
246+
new CustomStrategy(async (req: Request, done) => {
247+
const { username, password } = req.body;
248+
249+
if (!username || !password) {
250+
return done(null, false);
251+
}
252+
253+
try {
254+
const user = await authenticateUser(ldapConfig, username, password);
255+
return done(null, user || false);
256+
} catch (error: unknown) {
257+
const message = handleErrorAndLog(error, 'LDAP authentication error');
258+
return done(message);
259+
}
260+
}),
261+
);
262+
263+
passport.serializeUser((user: Partial<db.User>, done) => {
264+
done(null, user.username);
265+
});
266+
267+
passport.deserializeUser(async (username: string, done) => {
268+
try {
269+
const user = await db.findUser(username);
270+
done(null, user);
271+
} catch (error: unknown) {
272+
done(error, null);
273+
}
274+
});
275+
276+
return passport;
277+
};

0 commit comments

Comments
 (0)