From 656f2addcb71c9efeeb29743914a63f3c8d1e623 Mon Sep 17 00:00:00 2001 From: Kwangjin Ko Date: Wed, 1 Apr 2026 14:19:40 +0000 Subject: [PATCH 1/7] 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 e35608362..e79cfc7b8 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", @@ -10118,6 +10120,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, @@ -11681,6 +11695,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": { @@ -13221,6 +13247,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 4b5e6232f..c004ac1c5 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,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", @@ -129,6 +130,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 8dcbff18528c0211c3a98bea9833a4175449bce4 Mon Sep 17 00:00:00 2001 From: Kwangjin Ko Date: Wed, 1 Apr 2026 14:21:18 +0000 Subject: [PATCH 2/7] 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 | 100 ++++++ src/config/generated/config.ts | 118 +++++++- website/docs/configuration/reference.mdx | 370 ++++++++++++++++++++++- 3 files changed, 586 insertions(+), 2 deletions(-) diff --git a/config.schema.json b/config.schema.json index c72543037..9b7a6ff54 100644 --- a/config.schema.json +++ b/config.schema.json @@ -529,6 +529,106 @@ } }, "required": ["type", "enabled", "jwtConfig"] + }, + { + "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 and `{{username}}` as a placeholder for the login username. 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`)." + }, + "timeout": { + "type": "number", + "description": "LDAP client operation timeout in milliseconds." + }, + "connectTimeout": { + "type": "number", + "description": "LDAP client connection timeout in milliseconds." + }, + "searchTimeLimit": { + "type": "number", + "description": "LDAP search time limit in seconds." + }, + "searchSizeLimit": { + "type": "number", + "description": "Maximum number of LDAP search entries to return." + } + }, + "required": [ + "url", + "bindDN", + "bindPassword", + "searchBase", + "searchFilter", + "userGroupDN", + "adminGroupDN" + ] + } + }, + "required": ["type", "enabled", "ldapConfig"] } ] }, diff --git a/src/config/generated/config.ts b/src/config/generated/config.ts index 0a85e8e70..c6cc6f0a3 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; } @@ -241,6 +245,92 @@ export interface RoleMapping { [property: string]: any; } +/** + * LDAP connection and search configuration. + */ +export interface LDAPConfig { + /** + * DN of the admin group. Members of this group are granted admin privileges. + */ + adminGroupDN: string; + /** + * DN of the service account used to search for users, e.g. `cn=admin,dc=example,dc=com`. + */ + bindDN: string; + /** + * Password for the service account. + */ + bindPassword: string; + /** + * LDAP client connection timeout in milliseconds. + */ + connectTimeout?: number; + /** + * LDAP attribute for the user's display name. Defaults to `cn`. + */ + displayNameAttribute?: string; + /** + * LDAP attribute for the user's email. Defaults to `mail`. + */ + emailAttribute?: string; + /** + * Base DN for group membership searches. If omitted, each group's own DN (`userGroupDN` or + * `adminGroupDN`) is used as the search base. + */ + groupSearchBase?: string; + /** + * LDAP filter for group membership checks. Use `{{dn}}` as a placeholder for the user's DN + * and `{{username}}` as a placeholder for the login username. Defaults to `(member={{dn}})`. + */ + groupSearchFilter?: string; + /** + * Base DN for user searches, e.g. `ou=people,dc=example,dc=com`. + */ + searchBase: string; + /** + * LDAP search filter template. Use `{{username}}` as a placeholder for the login username. + * e.g. `(uid={{username}})`. + */ + searchFilter: string; + /** + * Maximum number of LDAP search entries to return. + */ + searchSizeLimit?: number; + /** + * LDAP search time limit in seconds. + */ + searchTimeLimit?: number; + /** + * Use STARTTLS to upgrade an ldap:// connection to TLS. Defaults to false. + */ + starttls?: boolean; + /** + * LDAP client operation timeout in milliseconds. + */ + timeout?: number; + /** + * LDAP attribute for the user's title. Defaults to `title`. + */ + titleAttribute?: string; + /** + * Node.js TLS options passed to the ldapts client (e.g. `rejectUnauthorized`, `ca`). + */ + tlsOptions?: { [key: string]: any }; + /** + * LDAP server URL, e.g. `ldap://ldap.example.com` or `ldaps://ldap.example.com`. + */ + url: string; + /** + * DN of the group a user must belong to in order to log in. + */ + userGroupDN: string; + /** + * LDAP attribute to use as the username. Defaults to `uid`. + */ + usernameAttribute?: string; + [property: string]: any; +} + /** * Additional OIDC configuration. */ @@ -256,6 +346,7 @@ export interface OidcConfig { export enum AuthenticationElementType { ActiveDirectory = 'ActiveDirectory', Jwt = 'jwt', + LDAP = 'ldap', Local = 'local', Openidconnect = 'openidconnect', } @@ -811,6 +902,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', ), @@ -834,6 +926,30 @@ const typeMap: any = { 'any', ), RoleMapping: o([{ json: 'admin', js: 'admin', typ: u(undefined, m('any')) }], 'any'), + LDAPConfig: o( + [ + { json: 'adminGroupDN', js: 'adminGroupDN', typ: '' }, + { json: 'bindDN', js: 'bindDN', typ: '' }, + { json: 'bindPassword', js: 'bindPassword', typ: '' }, + { json: 'connectTimeout', js: 'connectTimeout', typ: u(undefined, 3.14) }, + { json: 'displayNameAttribute', js: 'displayNameAttribute', typ: u(undefined, '') }, + { json: 'emailAttribute', js: 'emailAttribute', typ: u(undefined, '') }, + { json: 'groupSearchBase', js: 'groupSearchBase', typ: u(undefined, '') }, + { json: 'groupSearchFilter', js: 'groupSearchFilter', typ: u(undefined, '') }, + { json: 'searchBase', js: 'searchBase', typ: '' }, + { json: 'searchFilter', js: 'searchFilter', typ: '' }, + { json: 'searchSizeLimit', js: 'searchSizeLimit', typ: u(undefined, 3.14) }, + { json: 'searchTimeLimit', js: 'searchTimeLimit', typ: u(undefined, 3.14) }, + { json: 'starttls', js: 'starttls', typ: u(undefined, true) }, + { json: 'timeout', js: 'timeout', typ: u(undefined, 3.14) }, + { json: 'titleAttribute', js: 'titleAttribute', typ: u(undefined, '') }, + { json: 'tlsOptions', js: 'tlsOptions', typ: u(undefined, m('any')) }, + { json: 'url', js: 'url', typ: '' }, + { json: 'userGroupDN', js: 'userGroupDN', typ: '' }, + { json: 'usernameAttribute', js: 'usernameAttribute', typ: u(undefined, '') }, + ], + 'any', + ), OidcConfig: o( [ { json: 'callbackURL', js: 'callbackURL', typ: '' }, @@ -981,6 +1097,6 @@ const typeMap: any = { ], 'any', ), - AuthenticationElementType: ['ActiveDirectory', 'jwt', 'local', 'openidconnect'], + AuthenticationElementType: ['ActiveDirectory', 'jwt', 'ldap', 'local', 'openidconnect'], DatabaseType: ['fs', 'mongo'], }; diff --git a/website/docs/configuration/reference.mdx b/website/docs/configuration/reference.mdx index 361415bbf..e84828994 100644 --- a/website/docs/configuration/reference.mdx +++ b/website/docs/configuration/reference.mdx @@ -1235,6 +1235,7 @@ Specific value: `"fs"` | [Active Directory Auth Config](#authentication_items_oneOf_i1) | | [Open ID Connect Auth Config](#authentication_items_oneOf_i2) | | [JWT Auth Config](#authentication_items_oneOf_i3) | +| [LDAP Auth Config](#authentication_items_oneOf_i4) |
@@ -1736,6 +1737,373 @@ Specific value: `"jwt"`
+ +
+ +#### 16.1.5. Property `GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config` + +**Title:** LDAP Auth Config + +| | | +| ------------------------- | ---------------- | +| **Type** | `object` | +| **Required** | No | +| **Additional properties** | Any type allowed | + +**Description:** Configuration for generic LDAP authentication using ldapts. + +
+ + 16.1.5.1. [Required] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > type + +
+ +| | | +| ------------ | ------- | +| **Type** | `const` | +| **Required** | Yes | + +Specific value: `"ldap"` + +
+
+ +
+ + 16.1.5.2. [Required] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > enabled + +
+ +| | | +| ------------ | --------- | +| **Type** | `boolean` | +| **Required** | Yes | + +
+
+ +
+ + 16.1.5.3. [Required] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig + +
+ +| | | +| ------------------------- | ---------------- | +| **Type** | `object` | +| **Required** | Yes | +| **Additional properties** | Any type allowed | + +**Description:** LDAP connection and search configuration. + +
+ + 16.1.5.3.1. [Required] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > url + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | Yes | + +**Description:** LDAP server URL, e.g. `ldap://ldap.example.com` or `ldaps://ldap.example.com`. + +
+
+ +
+ + 16.1.5.3.2. [Required] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > bindDN + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | Yes | + +**Description:** DN of the service account used to search for users, e.g. `cn=admin,dc=example,dc=com`. + +
+
+ +
+ + 16.1.5.3.3. [Required] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > bindPassword + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | Yes | + +**Description:** Password for the service account. + +
+
+ +
+ + 16.1.5.3.4. [Required] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > searchBase + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | Yes | + +**Description:** Base DN for user searches, e.g. `ou=people,dc=example,dc=com`. + +
+
+ +
+ + 16.1.5.3.5. [Required] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > searchFilter + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | Yes | + +**Description:** LDAP search filter template. Use `{{username}}` as a placeholder for the login username. e.g. `(uid={{username}})`. + +
+
+ +
+ + 16.1.5.3.6. [Required] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > userGroupDN + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | Yes | + +**Description:** DN of the group a user must belong to in order to log in. + +
+
+ +
+ + 16.1.5.3.7. [Required] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > adminGroupDN + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | Yes | + +**Description:** DN of the admin group. Members of this group are granted admin privileges. + +
+
+ +
+ + 16.1.5.3.8. [Optional] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > groupSearchBase + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | No | + +**Description:** Base DN for group membership searches. If omitted, each group's own DN (`userGroupDN` or `adminGroupDN`) is used as the search base. + +
+
+ +
+ + 16.1.5.3.9. [Optional] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > groupSearchFilter + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | No | + +**Description:** LDAP filter for group membership checks. Use `{{dn}}` as a placeholder for the user's DN and `{{username}}` as a placeholder for the login username. Defaults to `(member={{dn}})`. + +
+
+ +
+ + 16.1.5.3.10. [Optional] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > usernameAttribute + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | No | + +**Description:** LDAP attribute to use as the username. Defaults to `uid`. + +
+
+ +
+ + 16.1.5.3.11. [Optional] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > emailAttribute + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | No | + +**Description:** LDAP attribute for the user's email. Defaults to `mail`. + +
+
+ +
+ + 16.1.5.3.12. [Optional] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > displayNameAttribute + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | No | + +**Description:** LDAP attribute for the user's display name. Defaults to `cn`. + +
+
+ +
+ + 16.1.5.3.13. [Optional] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > titleAttribute + +
+ +| | | +| ------------ | -------- | +| **Type** | `string` | +| **Required** | No | + +**Description:** LDAP attribute for the user's title. Defaults to `title`. + +
+
+ +
+ + 16.1.5.3.14. [Optional] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > starttls + +
+ +| | | +| ------------ | --------- | +| **Type** | `boolean` | +| **Required** | No | + +**Description:** Use STARTTLS to upgrade an ldap:// connection to TLS. Defaults to false. + +
+
+ +
+ + 16.1.5.3.15. [Optional] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > tlsOptions + +
+ +| | | +| ------------------------- | ---------------- | +| **Type** | `object` | +| **Required** | No | +| **Additional properties** | Any type allowed | + +**Description:** Node.js TLS options passed to the ldapts client (e.g. `rejectUnauthorized`, `ca`). + +
+
+ +
+ + 16.1.5.3.16. [Optional] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > timeout + +
+ +| | | +| ------------ | -------- | +| **Type** | `number` | +| **Required** | No | + +**Description:** LDAP client operation timeout in milliseconds. + +
+
+ +
+ + 16.1.5.3.17. [Optional] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > connectTimeout + +
+ +| | | +| ------------ | -------- | +| **Type** | `number` | +| **Required** | No | + +**Description:** LDAP client connection timeout in milliseconds. + +
+
+ +
+ + 16.1.5.3.18. [Optional] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > searchTimeLimit + +
+ +| | | +| ------------ | -------- | +| **Type** | `number` | +| **Required** | No | + +**Description:** LDAP search time limit in seconds. + +
+
+ +
+ + 16.1.5.3.19. [Optional] Property GitProxy configuration file > authentication > authentication items > oneOf > LDAP Auth Config > ldapConfig > searchSizeLimit + +
+ +| | | +| ------------ | -------- | +| **Type** | `number` | +| **Required** | No | + +**Description:** Maximum number of LDAP search entries to return. + +
+
+ +
+
+
@@ -2029,4 +2397,4 @@ Specific value: `"jwt"` ---------------------------------------------------------------------------------------------------------------------------- -Generated using [json-schema-for-humans](https://github.com/coveooss/json-schema-for-humans) on 2026-01-21 at 14:25:25 +0100 +Generated using [json-schema-for-humans](https://github.com/coveooss/json-schema-for-humans) on 2026-05-07 at 21:50:35 +0900 From 4dd9ab08c560ca868f4de062ea3042d67fcc43a3 Mon Sep 17 00:00:00 2001 From: Kwangjin Ko Date: Tue, 7 Apr 2026 23:21:16 +0900 Subject: [PATCH 3/7] 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 | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/proxy.config.json b/proxy.config.json index 715c38f48..d23c47f7d 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -52,6 +52,31 @@ "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": {}, + "timeout": 5000, + "connectTimeout": 5000, + "searchTimeLimit": 5, + "searchSizeLimit": 10 + } + }, { "type": "openidconnect", "enabled": false, From 4522f33cad81a0ceca59d0ccd9bcc810862c842f Mon Sep 17 00:00:00 2001 From: Kwangjin Ko Date: Wed, 1 Apr 2026 14:22:33 +0000 Subject: [PATCH 4/7] 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 | 298 +++++++++++++++++++++++++++++++++++ 1 file changed, 298 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..846a8b3b4 --- /dev/null +++ b/src/service/passport/ldap.ts @@ -0,0 +1,298 @@ +/** + * 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, + ...(typeof ldapConfig.timeout === 'number' ? { timeout: ldapConfig.timeout } : {}), + ...(typeof ldapConfig.connectTimeout === 'number' + ? { connectTimeout: ldapConfig.connectTimeout } + : {}), + }); +}; + +const getSearchOptions = (ldapConfig: LDAPConfig, filter: string) => ({ + scope: 'sub' as const, + filter, + ...(typeof ldapConfig.searchTimeLimit === 'number' + ? { timeLimit: ldapConfig.searchTimeLimit } + : {}), + ...(typeof ldapConfig.searchSizeLimit === 'number' + ? { sizeLimit: ldapConfig.searchSizeLimit } + : {}), +}); + +/** + * 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, + getSearchOptions(ldapConfig, 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, + username: string, +): Promise => { + const groupFilter = (ldapConfig.groupSearchFilter || '(member={{dn}})') + .replaceAll('{{dn}}', escapeFilterValue(userDN)) + .replaceAll('{{username}}', escapeFilterValue(username)); + + const searchBase = ldapConfig.groupSearchBase || groupDN; + + const { searchEntries } = await client.search( + searchBase, + getSearchOptions(ldapConfig, `(&(objectClass=*)${groupFilter})`), + ); + + return searchEntries.some( + (entry: Record) => + typeof entry.dn === 'string' && entry.dn.toLowerCase() === groupDN.toLowerCase(), + ); +}; + +/** + * 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: Verify user's password before doing group authorization work + const passwordValid = await verifyPassword(ldapConfig, userDN, password); + if (!passwordValid) { + return null; + } + + // Step 5: Check required user group membership + const isMember = await isUserInGroup( + client, + ldapConfig, + userDN, + ldapConfig.userGroupDN, + username, + ); + if (!isMember) { + console.log(`ldap: user ${username} is not a member of ${ldapConfig.userGroupDN}`); + return null; + } + + // Step 6: Check optional admin group membership + let isAdmin = false; + try { + isAdmin = await isUserInGroup(client, ldapConfig, userDN, ldapConfig.adminGroupDN, username); + } catch (error: unknown) { + handleErrorAndLog(error, 'Error checking admin group membership'); + } + + const email = entry[emailAttr]; + if (!email) { + throw new Error(`LDAP user "${username}" is missing required email attribute "${emailAttr}"`); + } + + // Step 7: Extract profile attributes and sync to database + const userObj = { + username: String(entry[usernameAttr] || username).toLowerCase(), + email: String(email).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: (error: unknown, user?: unknown) => void) => { + 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 232c23251af8068f72b42175c21310093211c0f6 Mon Sep 17 00:00:00 2001 From: Kwangjin Ko Date: Wed, 1 Apr 2026 14:23:19 +0000 Subject: [PATCH 5/7] 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 4ace8bc3e9b35614815a65db743757fc46e9147f Mon Sep 17 00:00:00 2001 From: Kwangjin Ko Date: Wed, 1 Apr 2026 14:24:29 +0000 Subject: [PATCH 6/7] 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 | 543 ++++++++++++++++++++ 1 file changed, 543 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..9bd2df3c8 --- /dev/null +++ b/test/services/passport/testLdapAuth.test.ts @@ -0,0 +1,543 @@ +/** + * 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[]; +let clientOptions: 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: {}, + timeout: 5000, + connectTimeout: 5000, + searchTimeLimit: 5, + searchSizeLimit: 10, +}; + +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 = []; + clientOptions = []; + 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); + clientOptions.push(opts); + 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(); + expect(clientOptions[0]).toMatchObject({ + timeout: 5000, + connectTimeout: 5000, + strictDN: true, + }); + expect(serviceClientMock.search).toHaveBeenNthCalledWith( + 1, + ldapConfig.searchBase, + expect.objectContaining({ + filter: '(uid=testuser)', + timeLimit: 5, + sizeLimit: 10, + }), + ); + }); + + 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: '', + }, + ], + }) + // Membership checks should not run for a bad password. + .mockRejectedValue(new Error('Unexpected group lookup')); + + // 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(); + expect(serviceClientMock.search).toHaveBeenCalledOnce(); + }); + + it('should surface required user group lookup errors', async () => { + serviceClientMock.search + .mockResolvedValueOnce({ + searchEntries: [ + { + dn: 'uid=testuser,ou=people,dc=test,dc=com', + uid: 'testuser', + mail: 'test@test.com', + cn: 'Test User', + title: '', + }, + ], + }) + .mockRejectedValueOnce(new Error('Group search failed')); + + 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).toBeTruthy(); + expect(user).toBeUndefined(); + expect(dbStub.updateUser).not.toHaveBeenCalled(); + }); + + it('should continue with admin=false if admin group lookup fails', 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', + }, + ], + }) + .mockResolvedValueOnce({ + searchEntries: [{ dn: 'cn=users,ou=groups,dc=test,dc=com' }], + }) + .mockRejectedValueOnce(new Error('Admin group search failed')); + + 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, + }); + expect(dbStub.updateUser).toHaveBeenCalledOnce(); + }); + + it('should fail if the LDAP email attribute is missing', async () => { + serviceClientMock.search + .mockResolvedValueOnce({ + searchEntries: [ + { + dn: 'uid=noemail,ou=people,dc=test,dc=com', + uid: 'noemail', + cn: 'No Email', + title: '', + }, + ], + }) + .mockResolvedValueOnce({ + searchEntries: [{ dn: 'cn=users,ou=groups,dc=test,dc=com' }], + }) + .mockResolvedValueOnce({ + searchEntries: [], + }); + + userClientMock.bind.mockResolvedValueOnce(undefined); + + const req = { body: { username: 'noemail', password: 'pass' } }; + const done = vi.fn(); + + await strategyCallback(req, done); + + expect(done).toHaveBeenCalledOnce(); + const [err, user] = done.mock.calls[0]; + expect(err).toContain('missing required email attribute'); + expect(user).toBeUndefined(); + 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'); + }); + + it('should support username placeholders in LDAP group filters', async () => { + const mod = await import('../../../src/service/passport/ldap.js'); + const client = { + search: vi.fn().mockResolvedValue({ + searchEntries: [{ dn: 'cn=users,ou=groups,dc=test,dc=com' }], + }), + }; + + const result = await mod.isUserInGroup( + client as any, + { + ...ldapConfig, + groupSearchFilter: '(memberUid={{username}})', + }, + 'uid=testuser,ou=people,dc=test,dc=com', + 'cn=users,ou=groups,dc=test,dc=com', + 'test*user', + ); + + expect(result).toBe(true); + expect(client.search).toHaveBeenCalledWith( + ldapConfig.groupSearchBase, + expect.objectContaining({ + filter: '(&(objectClass=*)(memberUid=test\\2auser))', + }), + ); + }); +}); From c2242380c3e6023518e62533a34c81761004e080 Mon Sep 17 00:00:00 2001 From: Kwangjin Ko Date: Thu, 7 May 2026 20:53:24 +0900 Subject: [PATCH 7/7] docs: clarify username/password auth methods Signed-off-by: Kwangjin Ko --- packages/git-proxy-cli/index.ts | 2 +- website/docs/quickstart/approve.mdx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/git-proxy-cli/index.ts b/packages/git-proxy-cli/index.ts index bb1ec937a..0810dbcab 100644 --- a/packages/git-proxy-cli/index.ts +++ b/packages/git-proxy-cli/index.ts @@ -604,7 +604,7 @@ yargs(hideBin(process.argv)) // eslint-disable-line @typescript-eslint/no-unused }) .command({ command: 'create-user', - describe: 'Create a new user', + describe: 'Create a new local database user', builder: { username: { describe: 'Username for the new user', diff --git a/website/docs/quickstart/approve.mdx b/website/docs/quickstart/approve.mdx index ebcd59ced..dba7419f9 100644 --- a/website/docs/quickstart/approve.mdx +++ b/website/docs/quickstart/approve.mdx @@ -106,6 +106,8 @@ $ npx -- @finos/git-proxy-cli login --username admin --password admin Login "admin" (admin): OK ``` +The CLI `create-user` command provisions users in GitProxy's local database only. It does not create users in external identity providers. + #### 3. Approve the push with `ID` Use the commit `ID` to approve your push with the CLI: