Files
ldap-identidy/apps/api/src/oidc/oidc-interaction.controller.ts
Bastian Wagner 49bf8d6168 logs
2026-07-17 12:39:48 +02:00

278 lines
12 KiB
TypeScript

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',
'<p class="message error" role="alert">Die SSO-Anmeldung ist abgelaufen oder konnte nicht zugeordnet werden. Bitte starten Sie die Anmeldung erneut aus der Anwendung.</p>',
),
);
return;
}
if (details.uid !== uid) {
response.status(400).send(this.page('Ungueltige Anfrage', '<p>Die OIDC-Interaktion ist ungueltig.</p>'));
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', '<p>Diese Interaktion wird noch nicht unterstuetzt.</p>'));
}
@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
? `<p class="message error" role="alert">${this.escape(errorMessage)}</p>`
: '';
return `
${error}
<form method="post" action="/interaction/${encodedUid}/login">
<label>Benutzername <input name="username" autocomplete="username" value="${this.escape(username)}" required></label>
<label>Passwort <input name="password" type="password" autocomplete="current-password" required autofocus></label>
<button type="submit">Anmelden</button>
</form>
<form method="post" action="/interaction/${encodedUid}/abort">
<button class="secondary" type="submit">Abbrechen</button>
</form>
<p class="form-link"><a href="${this.escape(this.registrationUrl)}">Neues Konto registrieren</a></p>
`;
}
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 `
<p class="intro">Die Anwendung <strong>${this.escape(clientName)}</strong> moechte auf dein Konto zugreifen.</p>
${redirectUri ? `<dl class="consent-details"><div><dt>Weiterleitung</dt><dd>${this.escape(redirectUri)}</dd></div></dl>` : ''}
<div class="scope-list" aria-label="Angeforderte Berechtigungen">
${this.scopeItems(scope)}
</div>
<form method="post" action="/interaction/${encodedUid}/confirm">
<button type="submit">Zugriff erlauben</button>
</form>
<form method="post" action="/interaction/${encodedUid}/abort">
<button class="secondary" type="submit">Ablehnen</button>
</form>
`;
}
private scopeItems(scope: string): string {
const scopes = scope
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean);
return scopes
.map(
(item) => `
<section class="scope-item">
<strong>${this.escape(item)}</strong>
<span>${this.escape(this.scopeDescription(item))}</span>
</section>
`,
)
.join('');
}
private scopeDescription(scope: string): string {
const descriptions: Record<string, string> = {
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 `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${this.escape(title)} - LDAP Portal</title>
<style>
body { background: #f5f7f9; color: #18202a; font-family: Inter, system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 20px; }
main { background: white; border: 1px solid #d8e0e7; border-radius: 8px; box-shadow: 0 16px 40px rgb(24 32 42 / 8%); max-width: 460px; padding: 28px; width: 100%; }
h1 { font-size: 1.45rem; margin: 0 0 22px; }
form { display: grid; gap: 16px; margin-top: 16px; }
label { display: grid; gap: 7px; font-weight: 700; }
input { border: 1px solid #bcc8d3; border-radius: 6px; font: inherit; min-height: 44px; padding: 10px 12px; }
button { background: #0f6b6e; border: 1px solid #0f6b6e; border-radius: 6px; color: white; cursor: pointer; font: inherit; font-weight: 700; min-height: 44px; padding: 10px 14px; }
button.secondary { background: white; color: #0f6b6e; }
a { color: #0f6b6e; font-weight: 700; text-decoration: none; }
a:hover { text-decoration: underline; }
.form-link { margin: 16px 0 0; text-align: center; }
.intro { color: #3a4551; line-height: 1.45; margin: 0 0 16px; }
.message { background: #edf7f4; border: 1px solid #b8ddd3; border-radius: 6px; color: #24564f; margin: 0 0 16px; padding: 12px; }
.message.error { background: #fff1f0; border-color: #efb5ae; color: #8d2b20; }
.consent-details { display: grid; gap: 10px; margin: 0 0 16px; }
.consent-details div { display: grid; gap: 5px; }
dt { color: #637083; font-size: 0.82rem; font-weight: 700; }
dd { margin: 0; overflow-wrap: anywhere; }
.scope-list { display: grid; gap: 8px; margin: 16px 0; }
.scope-item { border: 1px solid #d8e0e7; border-radius: 6px; display: grid; gap: 4px; padding: 10px 12px; }
.scope-item span { color: #637083; font-size: 0.9rem; line-height: 1.35; }
</style>
</head>
<body><main><h1>${this.escape(title)}</h1>${body}</main></body>
</html>`;
}
private escape(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
private get registrationUrl(): string {
const publicWebUrl = this.config.get<string>('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<void> {
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,
});
}
}