import { Injectable, InternalServerErrorException, Logger, OnModuleInit, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpAdapterHost } from '@nestjs/core'; import { InjectRepository } from '@nestjs/typeorm'; import { Request, Response } from 'express'; import type Provider from 'oidc-provider'; import type { AccountClaims, Adapter, Configuration, Interaction } from 'oidc-provider'; import { Repository } from 'typeorm'; import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes'; import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service'; import { AuditService } from '../audit/audit.service'; import { RequestContextService } from '../common/request-context.service'; import { LdapAuthService } from '../lldap/ldap-auth.service'; import { LldapService } from '../lldap/lldap.service'; import { OidcProviderStorageEntity } from './entities/oidc-provider-storage.entity'; import { OidcSigningKeyEntity } from './entities/oidc-signing-key.entity'; import { OidcSubjectEntity } from './entities/oidc-subject.entity'; import { OidcClientService } from './oidc-client.service'; import { TypeormOidcAdapter } from './typeorm-oidc.adapter'; type OidcModuleImport = typeof import('oidc-provider'); type JoseImport = typeof import('jose'); @Injectable() export class OidcProviderService implements OnModuleInit { private readonly logger = new Logger(OidcProviderService.name); private provider?: Provider; constructor( @InjectRepository(OidcProviderStorageEntity) private readonly storage: Repository, @InjectRepository(OidcSigningKeyEntity) private readonly signingKeys: Repository, @InjectRepository(OidcSubjectEntity) private readonly subjects: Repository, private readonly clients: OidcClientService, private readonly config: ConfigService, private readonly httpAdapterHost: HttpAdapterHost, private readonly ldapAuth: LdapAuthService, private readonly lldap: LldapService, private readonly audit: AuditService, private readonly applicationErrorLogger: ApplicationErrorLoggerService, private readonly requestContext: RequestContextService, ) {} async onModuleInit(): Promise { const oidc = await this.importOidcProvider(); const jwks = await this.loadOrCreateJwks(); const issuer = this.config.get('OIDC_ISSUER') ?? `http://localhost:${this.config.get('API_PORT') ?? 3000}`; this.provider = new oidc.default(issuer, this.buildConfiguration(jwks)); this.provider.proxy = this.config.get('OIDC_TRUST_PROXY') === 'true'; this.registerAuditEvents(this.provider); this.registerErrorEvents(this.provider); const expressApp = this.httpAdapterHost.httpAdapter.getInstance(); expressApp.use(this.provider.callback()); } async interactionDetails(request: Request, response: Response): Promise { return this.getProvider().interactionDetails(request, response); } async finishLogin( request: Request, response: Response, uid: string, username: string, password: string, ): Promise { const details = await this.interactionDetails(request, response); if (details.uid !== uid || details.prompt.name !== 'login') { throw new UnauthorizedException('Ungueltige OIDC-Interaktion.'); } const valid = await this.ldapAuth.verifyPassword(username, password); if (!valid) { await this.audit.record({ type: 'oidc.login_failed', username, ipAddress: request.ip, userAgent: request.headers['user-agent'] }); throw new UnauthorizedException('Ungueltige Zugangsdaten.'); } const account = await this.lldap.getAccount(username); const subject = account.uuid || account.id; await this.subjects.save(this.subjects.create({ subject, username: account.id })); await this.audit.record({ type: 'oidc.login_success', username: account.id, ipAddress: request.ip, userAgent: request.headers['user-agent'] }); await this.getProvider().interactionFinished( request, response, { login: { accountId: subject, acr: 'urn:ldap-portal:password', amr: ['pwd'], remember: true, ts: Math.floor(Date.now() / 1000), }, }, { mergeWithLastSubmission: false }, ); } async finishConsent( request: Request, response: Response, uid: string, options: { autoGranted?: boolean } = {}, ): Promise { const details = await this.interactionDetails(request, response); if (details.uid !== uid || details.prompt.name !== 'consent') { throw new UnauthorizedException('Ungueltige OIDC-Interaktion.'); } const clientId = String(details.params.client_id ?? ''); const accountId = details.session?.accountId; if (!clientId || !accountId) { throw new InternalServerErrorException('OIDC consent context is incomplete'); } const Grant = (this.getProvider() as unknown as { Grant: any }).Grant; const grant = details.grantId ? await Grant.find(details.grantId) : new Grant({ accountId, clientId }); const scope = String(details.params.scope ?? 'openid'); grant.addOIDCScope(scope); if (details.prompt.details?.missingOIDCClaims) { grant.addOIDCClaims(details.prompt.details.missingOIDCClaims); } const grantId = await grant.save(); await this.audit.record({ type: options.autoGranted ? 'oidc.consent_auto_granted' : 'oidc.consent_granted', username: accountId, metadata: { clientId, scope }, }); await this.getProvider().interactionFinished( request, response, { consent: { grantId } }, { mergeWithLastSubmission: true }, ); } async abortInteraction(request: Request, response: Response): Promise { await this.getProvider().interactionFinished( request, response, { error: 'access_denied', error_description: 'End-User aborted interaction', }, { mergeWithLastSubmission: false }, ); } async isFirstPartyClient(clientId: string): Promise { const client = await this.clients.findByClientId(clientId); return client?.firstParty === true; } private buildConfiguration(jwks: { keys: Record[] }): Configuration { return { adapter: (name: string): Adapter => new TypeormOidcAdapter(name, this.storage, this.clients), jwks, clientDefaults: { grant_types: ['authorization_code'], response_types: ['code'], id_token_signed_response_alg: 'ES256', token_endpoint_auth_method: 'client_secret_basic', }, claims: { openid: ['sub'], profile: ['name', 'preferred_username', 'given_name', 'family_name', 'updated_at'], email: ['email', 'email_verified'], groups: ['groups'], }, scopes: ['openid', 'profile', 'email', 'groups', 'offline_access'], routes: { authorization: '/oidc/auth', token: '/oidc/token', userinfo: '/oidc/me', jwks: '/oidc/jwks', end_session: '/oidc/session/end', revocation: '/oidc/token/revocation', introspection: '/oidc/token/introspection', }, interactions: { url: (_ctx, interaction) => `/interaction/${interaction.uid}`, }, features: { devInteractions: { enabled: false }, revocation: { enabled: true }, introspection: { enabled: true }, rpInitiatedLogout: { enabled: true }, }, cookies: { keys: [this.config.get('OIDC_COOKIE_SECRET') ?? this.config.getOrThrow('TOKEN_SECRET')], short: { httpOnly: true, sameSite: 'lax', secure: this.config.get('NODE_ENV') === 'production', }, long: { httpOnly: true, sameSite: 'lax', secure: this.config.get('NODE_ENV') === 'production', }, }, pkce: { required: () => true, }, ttl: { AccessToken: 10 * 60, AuthorizationCode: 10 * 60, IdToken: 10 * 60, RefreshToken: 14 * 24 * 60 * 60, Session: 8 * 60 * 60, }, findAccount: async (_ctx, sub) => { const subject = await this.subjects.findOneBy({ subject: sub }); if (!subject) { return undefined; } return { accountId: sub, claims: async () => this.claimsFor(subject.username, sub), }; }, }; } private async claimsFor(username: string, sub: string): Promise { const account = await this.lldap.getAccount(username); const claims: AccountClaims = { sub, preferred_username: account.id, name: account.displayName || account.id, email: account.email, email_verified: Boolean(account.email), given_name: account.firstName, family_name: account.lastName, updated_at: Math.floor(new Date(account.creationDate).getTime() / 1000), }; const client = await this.currentClient(); if (!client || client.includeGroups) { claims.groups = account.groups.map((group) => group.displayName); } return claims; } private async currentClient() { const oidcModule = await this.importOidcProvider(); const ctx = oidcModule.Provider.ctx; const clientId = ctx?.oidc?.client?.clientId; return clientId ? this.clients.findByClientId(clientId) : null; } private async loadOrCreateJwks(): Promise<{ keys: Record[] }> { const active = await this.signingKeys.find({ where: { active: true }, order: { createdAt: 'DESC' } }); if (active.length) { return { keys: active.map((key) => key.jwk) }; } const jose = await this.importJose(); const { privateKey } = await jose.generateKeyPair('ES256', { extractable: true }); const jwk = (await jose.exportJWK(privateKey)) as Record; jwk.kid = `sig-${Date.now()}`; jwk.alg = 'ES256'; jwk.use = 'sig'; await this.signingKeys.save(this.signingKeys.create({ kid: String(jwk.kid), active: true, jwk })); return { keys: [jwk] }; } private registerAuditEvents(provider: Provider): void { provider.on('authorization_code.saved', (code) => { void this.audit.record({ type: 'oidc.authorization_code_saved', username: code.accountId, metadata: { clientId: code.clientId } }); }); provider.on('access_token.issued', (token) => { void this.audit.record({ type: 'oidc.access_token_issued', username: token.accountId, metadata: { clientId: token.clientId } }); }); provider.on('interaction.started', (ctx, prompt) => { const oidcContext = ctx as { path?: string; oidc?: { client?: { clientId?: string }; params?: Record; entities?: { Interaction?: { uid?: string } }; }; }; void this.audit.record({ type: 'oidc.interaction_started', metadata: { prompt: typeof prompt === 'object' && prompt && 'name' in prompt ? String(prompt.name) : undefined, path: oidcContext.path, clientId: oidcContext.oidc?.client?.clientId ?? oidcContext.oidc?.params?.client_id, redirectUri: oidcContext.oidc?.params?.redirect_uri, scope: oidcContext.oidc?.params?.scope, interactionUid: oidcContext.oidc?.entities?.Interaction?.uid, }, }); }); } private registerErrorEvents(provider: Provider): void { const eventSource = provider as unknown as { on(eventName: string, listener: (ctx: unknown, error: unknown) => void): void; }; const errorEvents = [ 'authorization.error', 'server_error', 'grant.error', 'userinfo.error', 'jwks.error', 'discovery.error', 'end_session.error', 'revocation.error', 'introspection.error', ]; for (const eventName of errorEvents) { eventSource.on(eventName, (ctx, error) => { this.consoleLogOidcProviderError(eventName, ctx, error); this.logOidcProviderError(eventName, ctx, error).catch((logError) => { const message = logError instanceof Error ? logError.message : String(logError); this.logger.error(`OIDC provider error logging failed for ${eventName}: ${message}`); }); }); } } private async logOidcProviderError(eventName: string, ctx: unknown, error: unknown): Promise { const oidcContext = ctx as { method?: string; path?: string; status?: number; query?: Record; req?: { headers?: Record }; oidc?: { route?: string; client?: { clientId?: string }; params?: Record; body?: Record; }; }; const params = oidcContext.oidc?.params ?? oidcContext.query ?? {}; const currentRequestContext = this.requestContext.get(); const correlationHeader = oidcContext.req?.headers?.['x-correlation-id']; const correlationId = currentRequestContext.correlationId ?? (Array.isArray(correlationHeader) ? correlationHeader[0] : correlationHeader); await this.applicationErrorLogger.log({ error, category: ApplicationErrorCategory.OIDC, code: eventName === 'server_error' ? ApplicationErrorCode.OIDC_PROVIDER_ERROR : ApplicationErrorCode.OIDC_AUTHORIZATION_ERROR, module: 'OidcModule', service: OidcProviderService.name, operation: eventName, requestContext: { correlationId, method: oidcContext.method, path: oidcContext.path, statusCode: oidcContext.status, }, context: { eventName, route: oidcContext.oidc?.route, clientId: oidcContext.oidc?.client?.clientId ?? params.client_id, redirectUri: params.redirect_uri, responseType: params.response_type, responseMode: params.response_mode, scope: params.scope, prompt: params.prompt, error: this.errorProperty(error, 'error'), errorDescription: this.errorProperty(error, 'error_description'), errorDetail: this.errorProperty(error, 'error_detail'), }, handled: true, }); } private consoleLogOidcProviderError(eventName: string, ctx: unknown, error: unknown): void { const oidcContext = ctx as { method?: string; path?: string; status?: number; query?: Record; req?: { headers?: Record }; oidc?: { route?: string; client?: { clientId?: string }; params?: Record; }; }; const params = oidcContext.oidc?.params ?? oidcContext.query ?? {}; const errorName = error instanceof Error ? error.name : typeof error; const errorMessage = error instanceof Error ? error.message : String(error); const errorStack = error instanceof Error ? error.stack : undefined; console.error( '[OIDC_PROVIDER_ERROR]', JSON.stringify({ eventName, errorName, errorMessage, method: oidcContext.method, path: oidcContext.path, status: oidcContext.status, route: oidcContext.oidc?.route, clientId: oidcContext.oidc?.client?.clientId ?? params.client_id, redirectUri: params.redirect_uri, responseType: params.response_type, scope: params.scope, host: oidcContext.req?.headers?.host, forwardedProto: oidcContext.req?.headers?.['x-forwarded-proto'], forwardedHost: oidcContext.req?.headers?.['x-forwarded-host'], }), errorStack ?? '', ); } private errorProperty(error: unknown, property: string): unknown { if (!error || typeof error !== 'object' || !(property in error)) { return undefined; } return (error as Record)[property]; } private getProvider(): Provider { if (!this.provider) { throw new InternalServerErrorException('OIDC provider is not initialized'); } return this.provider; } private async importOidcProvider(): Promise { return new Function('specifier', 'return import(specifier)')('oidc-provider') as Promise; } private async importJose(): Promise { return new Function('specifier', 'return import(specifier)')('jose') as Promise; } }