Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/src/authentication/providers/ldap/auth-ldap.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export class AuthProviderLDAPConfig {
@IsString({ each: true })
servers: string[]

@IsOptional()
@IsObject()
tlsOptions?: Record<string, any> & { ca: string | string[] | Buffer }

@IsString()
@IsNotEmpty()
baseDN: string
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Entry } from 'ldapts'
import type { ConnectionOptions } from 'node:tls'
import { LDAP_COMMON_ATTR, LDAP_LOGIN_ATTR } from './auth-ldap.constants'

export type LdapUserEntry = Entry &
Record<LDAP_LOGIN_ATTR | Exclude<(typeof LDAP_COMMON_ATTR)[keyof typeof LDAP_COMMON_ATTR], typeof LDAP_COMMON_ATTR.MEMBER_OF>, string> & {
[LDAP_COMMON_ATTR.MEMBER_OF]?: string[]
}
export type LdapCa = ConnectionOptions['ca']
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Test, TestingModule } from '@nestjs/testing'
import { Mocked } from 'jest-mock'
import { Client, InvalidCredentialsError } from 'ldapts'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { CONNECT_ERROR_CODE } from '../../../app.constants'
import { USER_PERMISSION, USER_ROLE } from '../../../applications/users/constants/user'
import { UserModel } from '../../../applications/users/models/user.model'
Expand Down Expand Up @@ -77,6 +80,7 @@ describe(AuthProviderLDAP.name, () => {
;(authProviderLDAP as any).ldapConfig = next
;(authProviderLDAP as any).isAD = [LDAP_LOGIN_ATTR.SAM, LDAP_LOGIN_ATTR.UPN].includes(next.attributes.login)
;(authProviderLDAP as any).hasServiceBind = Boolean(next.serviceBindDN && next.serviceBindPassword)
;(authProviderLDAP as any).clientOptionsPromise = (authProviderLDAP as any).buildClientOptions()
}

const mockBindResolve = () => {
Expand Down Expand Up @@ -511,4 +515,37 @@ describe(AuthProviderLDAP.name, () => {
const samLogin = (authProviderLDAP as any).buildLdapLogin('john')
expect(samLogin).toBe('SYNC\\john')
})

it('should load CA from file and keep inline CA values', async () => {
const tmpPath = await mkdtemp(path.join(tmpdir(), 'ldap-tls-'))
const caPath = path.join(tmpPath, 'ca.pem')
await writeFile(caPath, 'CA_PEM')

setLdapConfig({ tlsOptions: { ca: [caPath, 'INLINE_CA'] } })

const initialClientOptions = await (authProviderLDAP as any).clientOptionsPromise
expect(initialClientOptions.tlsOptions).toEqual({
ca: ['CA_PEM', 'INLINE_CA']
})

const initialCa = initialClientOptions.tlsOptions.ca
;(authProviderLDAP as any).ldapConfig.tlsOptions.ca = 'CHANGED_INLINE'
expect((await (authProviderLDAP as any).clientOptionsPromise).tlsOptions.ca).toBe(initialCa)

await rm(tmpPath, { recursive: true, force: true })
})

it('should warn and fallback when ca path is not readable', async () => {
const warnSpy = jest.spyOn(authProviderLDAP['logger'], 'warn').mockImplementation(() => undefined)
const unreadableCaPath = '/definitely/missing/ca.pem'

const ca = await (authProviderLDAP as any).readTlsCa(unreadableCaPath)

expect(ca).toBe(unreadableCaPath)
expect(warnSpy).toHaveBeenCalledWith(
expect.objectContaining({
msg: expect.stringContaining('unable to read ca path, assume inline PEM content')
})
)
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'
import { AndFilter, Client, ClientOptions, Entry, EqualityFilter, InvalidCredentialsError, OrFilter } from 'ldapts'
import { readFile } from 'node:fs/promises'
import { CONNECT_ERROR_CODE } from '../../../app.constants'
import { isPathIsReadable } from '../../../applications/files/utils/files'
import { USER_ROLE } from '../../../applications/users/constants/user'
import type { CreateUserDto, UpdateUserDto } from '../../../applications/users/dto/create-or-update-user.dto'
import { UserModel } from '../../../applications/users/models/user.model'
Expand All @@ -12,19 +14,15 @@ import type { AUTH_SCOPE } from '../../constants/scope'
import { AuthProvider } from '../auth-providers.models'
import type { AuthProviderLDAPConfig } from './auth-ldap.config'
import { ALL_LDAP_ATTRIBUTES, LDAP_COMMON_ATTR, LDAP_LOGIN_ATTR, LDAP_SEARCH_ATTR } from './auth-ldap.constants'

type LdapUserEntry = Entry &
Record<LDAP_LOGIN_ATTR | Exclude<(typeof LDAP_COMMON_ATTR)[keyof typeof LDAP_COMMON_ATTR], typeof LDAP_COMMON_ATTR.MEMBER_OF>, string> & {
[LDAP_COMMON_ATTR.MEMBER_OF]?: string[]
}
import type { LdapCa, LdapUserEntry } from './auth-ldap.interface'

@Injectable()
export class AuthProviderLDAP implements AuthProvider {
private readonly logger = new Logger(AuthProviderLDAP.name)
private readonly ldapConfig: AuthProviderLDAPConfig = configuration.auth.ldap
private readonly hasServiceBind = Boolean(this.ldapConfig.serviceBindDN && this.ldapConfig.serviceBindPassword)
private readonly isAD = this.ldapConfig.attributes.login === LDAP_LOGIN_ATTR.SAM || this.ldapConfig.attributes.login === LDAP_LOGIN_ATTR.UPN
private clientOptions: ClientOptions = { timeout: 6000, connectTimeout: 6000, url: '' }
private readonly clientOptionsPromise: Promise<ClientOptions> = this.buildClientOptions()

constructor(
private readonly usersManager: UsersManager,
Expand Down Expand Up @@ -100,8 +98,9 @@ export class AuthProviderLDAP implements AuthProvider {
// Generic LDAP: build DN from login attribute + baseDN
const bindUserDN = this.buildBindUserDN(ldapLogin)
let error: InvalidCredentialsError | any
const clientOptions = await this.clientOptionsPromise
for (const s of this.ldapConfig.servers) {
const client = new Client({ ...this.clientOptions, url: s })
const client = new Client({ ...clientOptions, url: s })
let attemptedBindDN = bindUserDN
try {
if (this.hasServiceBind) {
Expand Down Expand Up @@ -138,6 +137,48 @@ export class AuthProviderLDAP implements AuthProvider {
return false
}

private async buildClientOptions(): Promise<ClientOptions> {
const ca = await this.readTlsCa(this.ldapConfig.tlsOptions?.ca)
const tlsOptions =
this.ldapConfig.tlsOptions && typeof this.ldapConfig.tlsOptions === 'object'
? {
...this.ldapConfig.tlsOptions,
...(ca !== undefined ? { ca } : {})
}
: undefined

return {
timeout: 6000,
connectTimeout: 6000,
url: '',
...(tlsOptions ? { tlsOptions } : {})
}
}

private async readTlsCa(ca: LdapCa): Promise<LdapCa> {
if (Buffer.isBuffer(ca)) {
return ca
}
if (Array.isArray(ca)) {
const values = await Promise.all(ca.map((v) => this.readTlsCa(v)))
return values.flat().filter((v): v is string | Buffer => typeof v === 'string' || Buffer.isBuffer(v))
}
if (typeof ca !== 'string') {
this.logger.debug({ tag: this.readTlsCa.name, msg: 'ca file is not string or buffer' })
return undefined
}
if (!(await isPathIsReadable(ca))) {
this.logger.warn({ tag: this.readTlsCa.name, msg: 'unable to read ca path, assume inline PEM content' })
return ca
}
try {
return await readFile(ca, 'utf8')
} catch (e) {
this.logger.error({ tag: this.readTlsCa.name, msg: `unable to read ca path: ${e}` })
return ca
}
}

private async checkAccess(login: string, client: Client, bindUserDN?: string): Promise<LdapUserEntry | false> {
// Search for the LDAP entry and normalize attributes.
const result = await this.findUserEntry(login, client, bindUserDN)
Expand Down
13 changes: 10 additions & 3 deletions environment/environment.dist.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,16 @@ auth:
# Multiple servers are tried in order until a bind/search succeeds.
# required
servers: []
# baseDN: Distinguished name (e.g.: ou=people,dc=ldap,dc=sync-in,dc=com)
# Used as the search base for users, and for groups when adminGroup is a CN.
# required
# tlsOptions: Node.js TLS options used for the LDAP secure connection.
# Supports standard TLS options such as `ca`, `rejectUnauthorized`, etc.
# See: https://nodejs.org/api/tls.html
# https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions
# Example:
# tlsOptions:
# rejectUnauthorized: true
# ca: [/app/certs/ca.pem]
# optional
tlsOptions:
baseDN: ou=people,dc=ldap,dc=sync-in,dc=com
# filter, e.g: (acl=admin)
# Appended as-is to the LDAP search filter (trusted config).
Expand Down
Loading