import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Ber, BerWriter, Client } from 'ldapts'; import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes'; import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service'; import { markErrorAsLogged } from '../application-error-log/logged-error-marker'; import { RequestContextService } from '../common/request-context.service'; 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, private readonly applicationErrorLogger: ApplicationErrorLoggerService, private readonly requestContext: RequestContextService, ) {} 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) { await this.applicationErrorLogger.log({ error, category: ApplicationErrorCategory.EXTERNAL_API, code: ApplicationErrorCode.EXTERNAL_API_REQUEST_FAILED, module: 'LldapModule', service: LldapService.name, operation: 'setPassword', requestContext: { ...this.requestContext.get(), userId: username, }, context: { provider: 'LLDAP', protocol: 'LDAP', }, handled: true, }); const exception = new InternalServerErrorException(`LLDAP password change failed: ${this.errorMessage(error)}`); markErrorAsLogged(exception); throw exception; } 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 findGroupByDisplayName(displayName: string): Promise { const groups = await this.listGroups(); return groups.find((group) => group.displayName === displayName) ?? null; } 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`; let response: Response; try { const headers = await this.adminHeaders(); response = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json', ...headers, }, body: JSON.stringify({ query, variables }), }); } catch (error) { await this.logGraphqlFailure(error, endpoint, 'request_failed'); markErrorAsLogged(error); throw error; } 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'); } const exception = new InternalServerErrorException(`LLDAP GraphQL request failed: ${message}`); await this.logGraphqlFailure(exception, endpoint, 'bad_response', { status: response.status, statusText: response.statusText, errorMessages: payload.errors?.map((error) => error.message), }); markErrorAsLogged(exception); throw exception; } if (!payload.data) { const exception = new InternalServerErrorException('LLDAP GraphQL response did not contain data'); await this.logGraphqlFailure(exception, endpoint, 'missing_data', { status: response.status }); markErrorAsLogged(exception); throw exception; } 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'; } private async logGraphqlFailure( error: unknown, endpoint: string, reason: string, context: Record = {}, ): Promise { await this.applicationErrorLogger.log({ error, category: ApplicationErrorCategory.EXTERNAL_API, code: ApplicationErrorCode.EXTERNAL_API_REQUEST_FAILED, module: 'LldapModule', service: LldapService.name, operation: 'graphql', requestContext: this.requestContext.get(), context: { provider: 'LLDAP', endpoint, reason, ...context, }, handled: true, }); } }