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/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", 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/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, 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/src/service/passport/index.ts b/src/service/passport/index.ts index 1bfeca6d7..63d26e558 100644 --- a/src/service/passport/index.ts +++ b/src/service/passport/index.ts @@ -17,6 +17,7 @@ import passport, { type PassportStatic } from 'passport'; import * as local from './local'; import * as activeDirectory from './activeDirectory'; +import * as ldap from './ldap'; import * as oidc from './oidc'; import * as config from '../../config'; import { AuthenticationElement } from '../../config/generated/config'; @@ -30,6 +31,7 @@ type StrategyModule = { export const authStrategies: Record = { local, activedirectory: activeDirectory, + ldap, openidconnect: oidc, }; diff --git a/src/service/passport/ldap.ts b/src/service/passport/ldap.ts new file mode 100644 index 000000000..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; +}; diff --git a/src/service/routes/auth.ts b/src/service/routes/auth.ts index a03c80480..f621a586b 100644 --- a/src/service/routes/auth.ts +++ b/src/service/routes/auth.ts @@ -21,6 +21,7 @@ import { getAuthMethods } from '../../config'; import * as db from '../../db'; import * as passportLocal from '../passport/local'; import * as passportAD from '../passport/activeDirectory'; +import * as passportLdap from '../passport/ldap'; import { User } from '../../db/types'; import { AuthenticationElement } from '../../config/generated/config'; @@ -52,7 +53,7 @@ router.get('/', (_req: Request, res: Response) => { }); // login strategies that will work with /login e.g. take username and password -const appropriateLoginStrategies = [passportLocal.type, passportAD.type]; +const appropriateLoginStrategies = [passportLocal.type, passportAD.type, passportLdap.type]; // getLoginStrategy fetches the enabled auth methods and identifies if there's an appropriate // auth method for username and password login. If there isn't it returns null, if there is it // returns the first. diff --git a/test/services/passport/testLdapAuth.test.ts b/test/services/passport/testLdapAuth.test.ts new file mode 100644 index 000000000..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))', + }), + ); + }); +}); 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 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: