import { Body, Controller, Get, Logger, Param, Post, Req, Res, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Request, Response } from 'express'; import type { Interaction } from 'oidc-provider'; import { ApplicationErrorCategory, ApplicationErrorCode } from '../application-error-log/application-error-codes'; import { ApplicationErrorLoggerService } from '../application-error-log/application-error-logger.service'; import { RequestContextService } from '../common/request-context.service'; import { OidcProviderService } from './oidc-provider.service'; @Controller('interaction') export class OidcInteractionController { private readonly logger = new Logger(OidcInteractionController.name); constructor( private readonly oidc: OidcProviderService, private readonly config: ConfigService, private readonly applicationErrorLogger: ApplicationErrorLoggerService, private readonly requestContext: RequestContextService, ) {} @Get(':uid') async view(@Param('uid') uid: string, @Req() request: Request, @Res() response: Response) { let details: Interaction; try { details = await this.oidc.interactionDetails(request, response); } catch (error) { this.logInteractionSessionError(error, uid, request).catch((logError) => { const message = logError instanceof Error ? logError.message : String(logError); this.logger.error(`OIDC interaction session logging failed: ${message}`); }); response .status(400) .send( this.page( 'Anmeldung abgelaufen', '', ), ); return; } if (details.uid !== uid) { response.status(400).send(this.page('Ungueltige Anfrage', '

Die OIDC-Interaktion ist ungueltig.

')); return; } if (details.prompt.name === 'login') { response.send(this.page('Anmelden', this.loginForm(uid))); return; } if (details.prompt.name === 'consent') { const clientId = String(details.params.client_id ?? ''); if (clientId && (await this.oidc.isFirstPartyClient(clientId))) { await this.oidc.finishConsent(request, response, uid, { autoGranted: true }); return; } response.send(this.page('Zugriff erlauben', this.consentView(uid, details))); return; } response.status(400).send(this.page('OIDC', '

Diese Interaktion wird noch nicht unterstuetzt.

')); } @Post(':uid/login') async login( @Param('uid') uid: string, @Body() body: { username?: string; password?: string }, @Req() request: Request, @Res() response: Response, ) { const username = body.username ?? ''; try { await this.oidc.finishLogin(request, response, uid, username, body.password ?? ''); } catch (error) { if (this.isInvalidCredentialsError(error)) { response .status(401) .send(this.page('Anmelden', this.loginForm(uid, username, 'Ungueltige Zugangsdaten.'))); return; } throw error; } } @Post(':uid/confirm') async confirm(@Param('uid') uid: string, @Req() request: Request, @Res() response: Response) { await this.oidc.finishConsent(request, response, uid); } @Post(':uid/abort') async abort(@Req() request: Request, @Res() response: Response) { await this.oidc.abortInteraction(request, response); } private loginForm(uid: string, username = '', errorMessage = ''): string { const encodedUid = encodeURIComponent(uid); const error = errorMessage ? `` : ''; return ` ${error}
`; } private consentView(uid: string, details: Interaction): string { const encodedUid = encodeURIComponent(uid); const clientId = String(details.params.client_id ?? ''); const clientName = String(details.params.name ?? (clientId || 'Unbekannte Anwendung')); const redirectUri = String(details.params.redirect_uri ?? ''); const scope = String(details.params.scope ?? 'openid'); return `

Die Anwendung ${this.escape(clientName)} moechte auf dein Konto zugreifen.

${redirectUri ? `` : ''}
${this.scopeItems(scope)}
`; } private scopeItems(scope: string): string { const scopes = scope .split(/\s+/) .map((item) => item.trim()) .filter(Boolean); return scopes .map( (item) => `
${this.escape(item)} ${this.escape(this.scopeDescription(item))}
`, ) .join(''); } private scopeDescription(scope: string): string { const descriptions: Record = { openid: 'Anmeldung per OpenID Connect bestaetigen.', profile: 'Profilinformationen wie Name und Anzeigename lesen.', email: 'E-Mail-Adresse lesen.', groups: 'Gruppenmitgliedschaften lesen.', offline_access: 'Laengerfristigen Zugriff ueber Refresh Tokens erlauben.', }; return descriptions[scope] ?? 'Diese Berechtigung wurde von der Anwendung angefordert.'; } private page(title: string, body: string): string { return ` ${this.escape(title)} - LDAP Portal

${this.escape(title)}

${body}
`; } private escape(value: string): string { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } private get registrationUrl(): string { const publicWebUrl = this.config.get('PUBLIC_WEB_URL') ?? 'http://localhost:4200'; return `${publicWebUrl.replace(/\/+$/, '')}/register`; } private isInvalidCredentialsError(error: unknown): boolean { return error instanceof UnauthorizedException && error.message === 'Ungueltige Zugangsdaten.'; } private async logInteractionSessionError(error: unknown, uid: string, request: Request): Promise { const cookieHeader = request.headers.cookie ?? ''; const hasCookieHeader = Boolean(cookieHeader); const hasInteractionCookie = /(?:^|;\s*)_interaction=/.test(cookieHeader); 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; const logPayload = { uid, errorName, errorMessage, hasCookieHeader, hasInteractionCookie, method: request.method, path: request.originalUrl || request.url, host: request.headers.host, forwardedProto: request.headers['x-forwarded-proto'], forwardedHost: request.headers['x-forwarded-host'], referer: request.headers.referer, userAgent: request.headers['user-agent'], correlationId: this.requestContext.get().correlationId, }; console.error('[OIDC_INTERACTION_SESSION_NOT_FOUND]', JSON.stringify(logPayload), errorStack ?? ''); this.logger.warn( `OIDC interaction session not found uid=${uid} error=${errorName}:${errorMessage} hasCookieHeader=${hasCookieHeader} hasInteractionCookie=${hasInteractionCookie} host=${request.headers.host ?? ''} forwardedProto=${request.headers['x-forwarded-proto'] ?? ''}`, ); await this.applicationErrorLogger.log({ error, category: ApplicationErrorCategory.OIDC, code: ApplicationErrorCode.OIDC_INTERACTION_SESSION_NOT_FOUND, module: 'OidcModule', service: OidcInteractionController.name, operation: 'viewInteraction', requestContext: { ...this.requestContext.get(), method: request.method, path: request.originalUrl || request.url, statusCode: 400, }, context: { uid, hasCookieHeader, hasInteractionCookie, forwardedProto: request.headers['x-forwarded-proto'], forwardedHost: request.headers['x-forwarded-host'], host: request.headers.host, referer: request.headers.referer, userAgent: request.headers['user-agent'], }, handled: true, }); } }