import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Ber, BerWriter, Client } from 'ldapts'; interface LldapUserInput { username: string; email: string; displayName: string; password: string; } interface LldapUser { id: string; email?: string; displayName?: string; } export interface LldapGroup { id: number; displayName: string; creationDate: string; uuid: string; attributes: LldapAttributeValue[]; users?: LldapUser[]; } export interface LldapUserUpdateInput { email?: string; displayName?: string; firstName?: string; lastName?: string; avatar?: string | null; } export interface LldapAttributeSchema { name: string; attributeType: string; isList: boolean; isVisible: boolean; isEditable: boolean; isHardcoded: boolean; isReadonly: boolean; } export interface LldapAttributeValue { name: string; value: string[]; schema: LldapAttributeSchema; } export interface LldapAccountGroup { id: number; displayName: string; creationDate: string; uuid: string; attributes: LldapAttributeValue[]; } export interface LldapAccountUser { id: string; email: string; displayName: string; firstName: string; lastName: string; avatar?: string | null; creationDate: string; uuid: string; attributes: LldapAttributeValue[]; groups: LldapAccountGroup[]; } @Injectable() export class LldapService { private static readonly passwordModifyOid = '1.3.6.1.4.1.4203.1.11.1'; private cachedHeaders?: { expiresAt: number; headers: Record }; constructor(private readonly config: ConfigService) {} async createUser(input: LldapUserInput): Promise { await this.graphql( `mutation CreateUser($user: CreateUserInput!) { createUser(user: $user) { id } }`, { user: { id: input.username, email: input.email, displayName: input.displayName, }, }, ); try { await this.setPassword(input.username, input.password); } catch (error) { await this.deleteUser(input.username).catch(() => undefined); throw error; } const defaultGroup = this.config.get('LLDAP_DEFAULT_GROUP'); if (defaultGroup) { await this.addUserToGroup(input.username, defaultGroup); } } async setPassword(username: string, password: string): Promise { const client = new Client({ url: this.config.getOrThrow('LLDAP_LDAP_URL') }); try { await client.bind(this.adminDn(), this.config.getOrThrow('LLDAP_ADMIN_PASSWORD')); await client.exop( LldapService.passwordModifyOid, this.passwordModifyRequestValue(this.userDn(username), password), ); } catch (error) { throw new InternalServerErrorException(`LLDAP password change failed: ${this.errorMessage(error)}`); } finally { await client.unbind().catch(() => undefined); } } async updateUser(username: string, input: LldapUserUpdateInput): Promise { await this.graphql( `mutation UpdateUser($user: UpdateUserInput!) { updateUser(user: $user) { ok } }`, { user: { id: username, ...input, avatar: input.avatar === null ? '' : input.avatar, }, }, ); } async deleteUser(username: string): Promise { await this.graphql( `mutation DeleteUser($userId: String!) { deleteUser(userId: $userId) { ok } }`, { userId: username }, ); } async findUserByUsername(username: string): Promise { const response = await this.graphql<{ user: LldapUser | null }>( `query User($id: String!) { user(userId: $id) { id email displayName } }`, { id: username }, ); return response.user ?? null; } async findUserByEmail(email: string): Promise { const response = await this.graphql<{ users: LldapUser[] }>( `query Users($filters: RequestFilter) { users(filters: $filters) { id email displayName } }`, { filters: { eq: { field: 'email', value: email } } }, ); return response.users?.[0] ?? null; } async getAccount(username: string): Promise { const response = await this.graphql<{ user: LldapAccountUser }>( `query Account($id: String!) { user(userId: $id) { id email displayName firstName lastName avatar creationDate uuid attributes { name value schema { name attributeType isList isVisible isEditable isHardcoded isReadonly } } groups { id displayName creationDate uuid attributes { name value schema { name attributeType isList isVisible isEditable isHardcoded isReadonly } } } } }`, { id: username }, ); return response.user; } async listUsers(): Promise { const response = await this.graphql<{ users: LldapAccountUser[] }>( `query Users { users { id email displayName firstName lastName avatar creationDate uuid attributes { name value schema { name attributeType isList isVisible isEditable isHardcoded isReadonly } } groups { id displayName creationDate uuid attributes { name value schema { name attributeType isList isVisible isEditable isHardcoded isReadonly } } } } }`, {}, ); return response.users; } async listGroups(): Promise { const response = await this.graphql<{ groups: LldapGroup[] }>( `query Groups { groups { id displayName creationDate uuid attributes { name value schema { name attributeType isList isVisible isEditable isHardcoded isReadonly } } users { id email displayName } } }`, {}, ); return response.groups; } async getGroup(groupId: number): Promise { const response = await this.graphql<{ group: LldapGroup }>( `query Group($groupId: Int!) { group(groupId: $groupId) { id displayName creationDate uuid attributes { name value schema { name attributeType isList isVisible isEditable isHardcoded isReadonly } } users { id email displayName } } }`, { groupId }, ); return response.group; } async createGroup(displayName: string): Promise { const response = await this.graphql<{ createGroupWithDetails: LldapGroup }>( `mutation CreateGroup($request: CreateGroupInput!) { createGroupWithDetails(request: $request) { id displayName creationDate uuid attributes { name value schema { name attributeType isList isVisible isEditable isHardcoded isReadonly } } } }`, { request: { displayName, attributes: [] } }, ); return response.createGroupWithDetails; } async updateGroup(groupId: number, displayName: string): Promise { await this.graphql( `mutation UpdateGroup($group: UpdateGroupInput!) { updateGroup(group: $group) { ok } }`, { group: { id: groupId, displayName } }, ); } async deleteGroup(groupId: number): Promise { await this.graphql( `mutation DeleteGroup($groupId: Int!) { deleteGroup(groupId: $groupId) { ok } }`, { groupId }, ); } async addUserToGroup(username: string, groupId: string | number): Promise { await this.graphql( `mutation AddUserToGroup($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) { ok } }`, { userId: username, groupId: Number(groupId) }, ); } async removeUserFromGroup(username: string, groupId: string | number): Promise { await this.graphql( `mutation RemoveUserFromGroup($userId: String!, $groupId: Int!) { removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } }`, { userId: username, groupId: Number(groupId) }, ); } private async graphql(query: string, variables: Record): Promise { const endpoint = `${this.config.getOrThrow('LLDAP_URL').replace(/\/$/, '')}/api/graphql`; const headers = await this.adminHeaders(); const response = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json', ...headers, }, body: JSON.stringify({ query, variables }), }); const payload = (await response.json().catch(() => ({}))) as { data?: T; errors?: Array<{ message?: string }>; }; if (!response.ok || payload.errors?.length) { const message = payload.errors?.map((error) => error.message).join('; ') || response.statusText; if (/not found/i.test(message)) { throw new NotFoundException('LLDAP user not found'); } throw new InternalServerErrorException(`LLDAP GraphQL request failed: ${message}`); } if (!payload.data) { throw new InternalServerErrorException('LLDAP GraphQL response did not contain data'); } return payload.data; } private async adminHeaders(): Promise> { const staticToken = this.config.get('LLDAP_GRAPHQL_TOKEN'); if (staticToken) { return { authorization: `Bearer ${staticToken}` }; } if (this.cachedHeaders && this.cachedHeaders.expiresAt > Date.now()) { return this.cachedHeaders.headers; } const baseUrl = this.config.getOrThrow('LLDAP_URL').replace(/\/$/, ''); const response = await fetch(`${baseUrl}/auth/simple/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: this.config.getOrThrow('LLDAP_ADMIN_USERNAME'), password: this.config.getOrThrow('LLDAP_ADMIN_PASSWORD'), }), }); const body = (await response.json().catch(() => ({}))) as Record; const cookie = response.headers.get('set-cookie'); const token = typeof body.token === 'string' ? body.token : typeof body.jwt === 'string' ? body.jwt : undefined; if (!response.ok || (!cookie && !token)) { throw new InternalServerErrorException('LLDAP admin login failed'); } const headers: Record = token ? { authorization: `Bearer ${token}` } : { cookie: cookie ?? '' }; this.cachedHeaders = { headers, expiresAt: Date.now() + 5 * 60_000 }; return headers; } private passwordModifyRequestValue(userDn: string, newPassword: string): Buffer { const writer = new BerWriter(); writer.startSequence(Ber.Sequence | Ber.Constructor); writer.writeString(userDn, 0x80); writer.writeString(newPassword, 0x82); writer.endSequence(); return writer.buffer; } private adminDn(): string { return this.userDn(this.config.getOrThrow('LLDAP_ADMIN_USERNAME')); } private userDn(username: string): string { const baseDn = this.config.getOrThrow('LLDAP_BASE_DN'); return `uid=${this.escapeDn(username)},ou=people,${baseDn}`; } private escapeDn(value: string): string { return value.replace(/[\\,+"<>;=]/g, (char) => `\\${char}`); } private errorMessage(error: unknown): string { return error instanceof Error ? error.message : 'unknown error'; } }