This commit is contained in:
Bastian Wagner
2026-07-17 14:34:44 +02:00
parent 357642ad22
commit d30d9cfdde
15 changed files with 569 additions and 152 deletions

View File

@@ -5,6 +5,14 @@ WEB_PORT=4200
PUBLIC_WEB_URL=http://localhost:4200
API_BASE_URL=/api
APP_PRODUCT_NAME=LDAP Portal
APP_COMPANY_NAME=LDAP Portal
APP_PRIMARY_COLOR=#0f6b6e
APP_SUPPORT_EMAIL=support@example.com
APP_LOGO_URL=
APP_IMPRINT_URL=
APP_PRIVACY_URL=
DATABASE_URL=mysql://ldap_portal:change-me@mysql.example.com:3306/ldap_portal
DATABASE_SSL=false
JWT_SECRET=change-me-long-random-jwt-secret

View File

@@ -130,6 +130,9 @@ Die wichtigsten Variablen aus `.env.example`:
| `API_PORT` | Interner Port der NestJS API, im Container standardmaessig `3000`. |
| `PUBLIC_WEB_URL` | Externe Web-URL fuer CORS, Mail-Links und Registrierung aus dem SSO-Login. |
| `API_BASE_URL` | API-Basis aus Sicht des Browsers, im Single Container `/api`. |
| `APP_PRODUCT_NAME`, `APP_COMPANY_NAME` | Zentrale Branding-Namen fuer OIDC-Seiten und Mail-Templates. |
| `APP_PRIMARY_COLOR` | Zentrale Primaerfarbe als sechsstelliger Hex-Wert. |
| `APP_SUPPORT_EMAIL`, `APP_LOGO_URL`, `APP_IMPRINT_URL`, `APP_PRIVACY_URL` | Zentrale Branding-Werte; Bild- und Link-URLs muessen HTTPS verwenden. |
| `DATABASE_URL` | MySQL-Verbindungs-URL. Alternativ `DB_HOST`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD`, `DB_DATABASE`. |
| `DATABASE_SSL` | `true`, wenn MySQL TLS verlangt. |
| `JWT_SECRET` | Signatur-Secret fuer Portal-JWTs. |
@@ -156,6 +159,8 @@ Die wichtigsten Variablen aus `.env.example`:
Hinweis: `REGISTRATION_MANAGER_GROUP`, `GROUP_MANAGER_GROUP` und `AUDIT_VIEWER_GROUP` stehen aktuell in `.env.example`, werden im Code aber nicht ausgewertet. Die Admin-Gruppennamen sind derzeit fest verdrahtet, siehe "Admin-Rollen".
Die `APP_*`-Werte haben Vorrang. Solange sie nicht gesetzt sind, verwendet die Anwendung fuer die Rueckwaertskompatibilitaet die entsprechenden `MAIL_*`-Werte. Die OIDC-Seiten liegen unter `apps/api/src/oidc/templates` und werden beim API-Build nach `dist/oidc/templates` kopiert. Sie werden ohne clientseitiges JavaScript direkt durch NestJS gerendert.
## Datenbank
Die App nutzt TypeORM mit MySQL. In `NODE_ENV=production` ist `synchronize` deaktiviert. Fuer produktive Deployments muss das Schema vorab vorhanden sein oder es muessen Migrationen ergaenzt und ausgefuehrt werden.

View File

@@ -8,6 +8,11 @@
"include": "mail/templates/**/*",
"outDir": "dist",
"watchAssets": true
},
{
"include": "oidc/templates/**/*",
"outDir": "dist",
"watchAssets": true
}
]
}

View File

@@ -1,9 +1,6 @@
const { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } = require('node:fs');
const { join } = require('node:path');
const source = join(__dirname, '..', 'src', 'mail', 'templates');
const target = join(__dirname, '..', 'dist', 'mail', 'templates');
function copyDirectory(from, to) {
if (!existsSync(from)) {
return;
@@ -21,4 +18,9 @@ function copyDirectory(from, to) {
}
}
copyDirectory(source, target);
for (const feature of ['mail', 'oidc']) {
copyDirectory(
join(__dirname, '..', 'src', feature, 'templates'),
join(__dirname, '..', 'dist', feature, 'templates'),
);
}

View File

@@ -0,0 +1,44 @@
import { ConfigService } from '@nestjs/config';
export interface ApplicationBranding {
productName: string;
companyName: string;
primaryColor: string;
supportEmail: string;
logoUrl?: string;
imprintUrl?: string;
privacyUrl?: string;
}
export function applicationBranding(config: ConfigService): ApplicationBranding {
return {
productName: configValue(config, 'APP_PRODUCT_NAME', 'MAIL_PRODUCT_NAME') ?? 'LDAP Portal',
companyName: configValue(config, 'APP_COMPANY_NAME', 'MAIL_COMPANY_NAME') ?? 'LDAP Portal',
primaryColor: validColor(configValue(config, 'APP_PRIMARY_COLOR', 'MAIL_PRIMARY_COLOR')),
supportEmail: configValue(config, 'APP_SUPPORT_EMAIL', 'MAIL_SUPPORT_EMAIL') ?? 'support@example.com',
logoUrl: validHttpsUrl(configValue(config, 'APP_LOGO_URL', 'MAIL_LOGO_URL')),
imprintUrl: validHttpsUrl(configValue(config, 'APP_IMPRINT_URL', 'MAIL_IMPRINT_URL')),
privacyUrl: validHttpsUrl(configValue(config, 'APP_PRIVACY_URL', 'MAIL_PRIVACY_URL')),
};
}
function configValue(config: ConfigService, primaryKey: string, fallbackKey: string): string | undefined {
return config.get<string>(primaryKey)?.trim() || config.get<string>(fallbackKey)?.trim() || undefined;
}
function validColor(value?: string): string {
return value && /^#[0-9a-f]{6}$/i.test(value) ? value : '#0f6b6e';
}
function validHttpsUrl(value?: string): string | undefined {
if (!value) {
return undefined;
}
try {
const url = new URL(value);
return url.protocol === 'https:' ? url.toString() : undefined;
} catch {
return undefined;
}
}

View File

@@ -1,23 +1,11 @@
import { ConfigService } from '@nestjs/config';
import {
ApplicationBranding,
applicationBranding,
} from '../common/application-branding';
export interface MailBranding {
productName: string;
companyName: string;
primaryColor: string;
supportEmail: string;
logoUrl?: string;
imprintUrl?: string;
privacyUrl?: string;
}
export type MailBranding = ApplicationBranding;
export function mailBranding(config: ConfigService): MailBranding {
return {
productName: config.get<string>('MAIL_PRODUCT_NAME') ?? 'LDAP Portal',
companyName: config.get<string>('MAIL_COMPANY_NAME') ?? 'LDAP Portal',
primaryColor: config.get<string>('MAIL_PRIMARY_COLOR') ?? '#0f6b6e',
supportEmail: config.get<string>('MAIL_SUPPORT_EMAIL') ?? 'support@example.com',
logoUrl: config.get<string>('MAIL_LOGO_URL') || undefined,
imprintUrl: config.get<string>('MAIL_IMPRINT_URL') || undefined,
privacyUrl: config.get<string>('MAIL_PRIVACY_URL') || undefined,
};
return applicationBranding(config);
}

View File

@@ -0,0 +1,66 @@
import { OidcInteractionTemplateService } from './oidc-interaction-template.service';
describe('OidcInteractionTemplateService', () => {
const values: Record<string, string> = {
APP_PRODUCT_NAME: 'ForgeCore Auth',
APP_COMPANY_NAME: 'ForgeCore',
APP_PRIMARY_COLOR: '#126466',
APP_SUPPORT_EMAIL: 'support@example.com',
APP_LOGO_URL: 'https://cdn.example.com/logo.png',
};
const config = { get: jest.fn((key: string) => values[key]) };
const service = new OidcInteractionTemplateService(config as any);
it('renders the login page with branding and encoded form actions', async () => {
const html = await service.renderLogin({
uid: 'uid/with spaces',
username: 'maria',
registrationUrl: 'https://portal.example.com/register',
});
expect(html).toContain('<title>Anmelden - ForgeCore Auth</title>');
expect(html).toContain('https://cdn.example.com/logo.png');
expect(html).toContain('/interaction/uid%2Fwith%20spaces/login');
expect(html).toContain('value="maria"');
});
it('escapes usernames and error messages', async () => {
const html = await service.renderLogin({
uid: 'uid',
username: '<script>alert(1)</script>',
errorMessage: '<b>Fehler</b>',
registrationUrl: 'https://portal.example.com/register',
});
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(html).toContain('&lt;b&gt;Fehler&lt;/b&gt;');
expect(html).not.toContain('<script>');
});
it('renders escaped consent details and scope descriptions', async () => {
const html = await service.renderConsent({
uid: 'consent-id',
clientName: '<Admin Portal>',
redirectUri: 'https://client.example.com/callback?x=1&y=2',
scopes: ['openid', '<custom>'],
});
expect(html).toContain('&lt;Admin Portal&gt;');
expect(html).toContain('https://client.example.com/callback?x&#x3D;1&amp;y&#x3D;2');
expect(html).toContain('Anmeldung per OpenID Connect bestaetigen.');
expect(html).toContain('&lt;custom&gt;');
expect(html).toContain('/interaction/consent-id/confirm');
});
it('falls back to mail branding when application branding is absent', async () => {
const fallbackConfig = {
get: jest.fn((key: string) => ({ MAIL_PRODUCT_NAME: 'Legacy Portal' })[key]),
};
const fallbackService = new OidcInteractionTemplateService(fallbackConfig as any);
const html = await fallbackService.renderError({ title: 'Fehler', message: 'Nicht verfuegbar.' });
expect(html).toContain('<title>Fehler - Legacy Portal</title>');
expect(html).toContain('Nicht verfuegbar.');
});
});

View File

@@ -0,0 +1,86 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import Handlebars from 'handlebars';
import { applicationBranding } from '../common/application-branding';
const oidcTemplateDir = join(__dirname, 'templates');
export interface OidcLoginPageInput {
uid: string;
username?: string;
errorMessage?: string;
registrationUrl: string;
}
export interface OidcConsentPageInput {
uid: string;
clientName: string;
redirectUri?: string;
scopes: string[];
}
export interface OidcErrorPageInput {
title: string;
message: string;
}
@Injectable()
export class OidcInteractionTemplateService {
constructor(private readonly config: ConfigService) {}
async renderLogin(input: OidcLoginPageInput): Promise<string> {
const encodedUid = encodeURIComponent(input.uid);
return this.render('login', 'Anmelden', {
username: input.username ?? '',
errorMessage: input.errorMessage,
loginAction: `/interaction/${encodedUid}/login`,
abortAction: `/interaction/${encodedUid}/abort`,
registrationUrl: input.registrationUrl,
});
}
async renderConsent(input: OidcConsentPageInput): Promise<string> {
const encodedUid = encodeURIComponent(input.uid);
return this.render('consent', 'Zugriff erlauben', {
clientName: input.clientName,
redirectUri: input.redirectUri,
scopes: input.scopes.map((name) => ({ name, description: this.scopeDescription(name) })),
confirmAction: `/interaction/${encodedUid}/confirm`,
abortAction: `/interaction/${encodedUid}/abort`,
});
}
async renderError(input: OidcErrorPageInput): Promise<string> {
return this.render('error', input.title, { message: input.message });
}
private async render(templateName: string, title: string, context: Record<string, unknown>): Promise<string> {
const [layoutSource, templateSource] = await Promise.all([
readFile(join(oidcTemplateDir, 'layouts', 'base.hbs'), 'utf8'),
readFile(join(oidcTemplateDir, `${templateName}.hbs`), 'utf8'),
]);
const branding = applicationBranding(this.config);
const body = Handlebars.compile(templateSource)(context);
return Handlebars.compile(layoutSource)({
...context,
branding,
title,
body,
});
}
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.';
}
}

View File

@@ -0,0 +1,154 @@
import { Logger, UnauthorizedException } from '@nestjs/common';
import { OidcInteractionController } from './oidc-interaction.controller';
describe('OidcInteractionController', () => {
const request = {
method: 'GET',
originalUrl: '/interaction/uid',
url: '/interaction/uid',
headers: { host: 'auth.example.com', 'x-forwarded-proto': 'https' },
} as any;
function responseMock() {
const response: any = {
send: jest.fn(),
status: jest.fn(),
};
response.status.mockReturnValue(response);
return response;
}
function createController() {
const oidc = {
interactionDetails: jest.fn(),
isFirstPartyClient: jest.fn().mockResolvedValue(false),
finishConsent: jest.fn().mockResolvedValue(undefined),
finishLogin: jest.fn().mockResolvedValue(undefined),
abortInteraction: jest.fn().mockResolvedValue(undefined),
};
const templates = {
renderLogin: jest.fn().mockResolvedValue('<html>login</html>'),
renderConsent: jest.fn().mockResolvedValue('<html>consent</html>'),
renderError: jest.fn().mockResolvedValue('<html>error</html>'),
};
const config = { get: jest.fn().mockReturnValue('https://portal.example.com/') };
const errorLogger = { log: jest.fn().mockResolvedValue(undefined) };
const requestContext = { get: jest.fn().mockReturnValue({ correlationId: 'correlation-id' }) };
const controller = new OidcInteractionController(
oidc as any,
config as any,
templates as any,
errorLogger as any,
requestContext as any,
);
return { controller, oidc, templates, errorLogger };
}
it('renders the login interaction without changing its route', async () => {
const { controller, oidc, templates } = createController();
const response = responseMock();
oidc.interactionDetails.mockResolvedValue({ uid: 'uid', prompt: { name: 'login' }, params: {} });
await controller.view('uid', request, response);
expect(templates.renderLogin).toHaveBeenCalledWith({
uid: 'uid',
registrationUrl: 'https://portal.example.com/register',
});
expect(response.send).toHaveBeenCalledWith('<html>login</html>');
});
it('automatically finishes consent for first-party clients', async () => {
const { controller, oidc, templates } = createController();
const response = responseMock();
oidc.interactionDetails.mockResolvedValue({
uid: 'uid',
prompt: { name: 'consent' },
params: { client_id: 'first-party' },
});
oidc.isFirstPartyClient.mockResolvedValue(true);
await controller.view('uid', request, response);
expect(oidc.finishConsent).toHaveBeenCalledWith(request, response, 'uid', { autoGranted: true });
expect(templates.renderConsent).not.toHaveBeenCalled();
});
it('renders consent details for third-party clients', async () => {
const { controller, oidc, templates } = createController();
const response = responseMock();
oidc.interactionDetails.mockResolvedValue({
uid: 'uid',
prompt: { name: 'consent' },
params: {
client_id: 'client-id',
name: 'Listify',
redirect_uri: 'https://listify.example.com/callback',
scope: 'openid profile email',
},
});
await controller.view('uid', request, response);
expect(templates.renderConsent).toHaveBeenCalledWith({
uid: 'uid',
clientName: 'Listify',
redirectUri: 'https://listify.example.com/callback',
scopes: ['openid', 'profile', 'email'],
});
});
it('keeps a successful login delegated to the provider', async () => {
const { controller, oidc } = createController();
const response = responseMock();
await controller.login('uid', { username: 'maria', password: 'secret' }, request, response);
expect(oidc.finishLogin).toHaveBeenCalledWith(request, response, 'uid', 'maria', 'secret');
expect(response.send).not.toHaveBeenCalled();
});
it('returns the rendered login page for invalid credentials', async () => {
const { controller, oidc, templates } = createController();
const response = responseMock();
oidc.finishLogin.mockRejectedValue(new UnauthorizedException('Ungueltige Zugangsdaten.'));
await controller.login('uid', { username: 'maria', password: 'wrong' }, request, response);
expect(response.status).toHaveBeenCalledWith(401);
expect(templates.renderLogin).toHaveBeenCalledWith(expect.objectContaining({
uid: 'uid',
username: 'maria',
errorMessage: 'Ungueltige Zugangsdaten.',
}));
});
it('logs an expired interaction and returns a controlled page', async () => {
const { controller, oidc, templates, errorLogger } = createController();
const response = responseMock();
oidc.interactionDetails.mockRejectedValue(new Error('SessionNotFound'));
jest.spyOn(console, 'error').mockImplementation();
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
await controller.view('uid', request, response);
expect(errorLogger.log).toHaveBeenCalledWith(expect.objectContaining({
code: 'OIDC_INTERACTION_SESSION_NOT_FOUND',
handled: true,
}));
expect(templates.renderError).toHaveBeenCalledWith(expect.objectContaining({ title: 'Anmeldung abgelaufen' }));
expect(response.status).toHaveBeenCalledWith(400);
jest.restoreAllMocks();
});
it('rethrows unexpected login errors for global error handling', async () => {
const { controller, oidc } = createController();
const response = responseMock();
oidc.finishLogin.mockRejectedValue(new Error('provider unavailable'));
await expect(
controller.login('uid', { username: 'maria', password: 'secret' }, request, response),
).rejects.toThrow('provider unavailable');
});
});

View File

@@ -5,6 +5,7 @@ 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 { OidcInteractionTemplateService } from './oidc-interaction-template.service';
import { OidcProviderService } from './oidc-provider.service';
@Controller('interaction')
@@ -14,6 +15,7 @@ export class OidcInteractionController {
constructor(
private readonly oidc: OidcProviderService,
private readonly config: ConfigService,
private readonly templates: OidcInteractionTemplateService,
private readonly applicationErrorLogger: ApplicationErrorLoggerService,
private readonly requestContext: RequestContextService,
) {}
@@ -25,21 +27,24 @@ export class OidcInteractionController {
details = await this.oidc.interactionDetails(request, response);
} catch (error) {
await this.logInteractionSessionError(error, uid, request);
response.status(400).send(
this.page(
'Anmeldung abgelaufen',
'<p>Die Anmeldung konnte nicht fortgesetzt werden. Bitte starte den Login in der Anwendung erneut.</p>',
),
);
const page = await this.templates.renderError({
title: 'Anmeldung abgelaufen',
message: 'Die Anmeldung konnte nicht fortgesetzt werden. Bitte starte den Login in der Anwendung erneut.',
});
response.status(400).send(page);
return;
}
if (details.uid !== uid) {
response.status(400).send(this.page('Ungueltige Anfrage', '<p>Die OIDC-Interaktion ist ungueltig.</p>'));
const page = await this.templates.renderError({
title: 'Ungueltige Anfrage',
message: 'Die OIDC-Interaktion ist ungueltig.',
});
response.status(400).send(page);
return;
}
if (details.prompt.name === 'login') {
response.send(this.page('Anmelden', this.loginForm(uid)));
response.send(await this.templates.renderLogin({ uid, registrationUrl: this.registrationUrl }));
return;
}
@@ -50,11 +55,21 @@ export class OidcInteractionController {
return;
}
response.send(this.page('Zugriff erlauben', this.consentView(uid, details)));
const clientName = String(details.params.name ?? (clientId || 'Unbekannte Anwendung'));
const redirectUri = String(details.params.redirect_uri ?? '');
const scopes = String(details.params.scope ?? 'openid')
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean);
response.send(await this.templates.renderConsent({ uid, clientName, redirectUri, scopes }));
return;
}
response.status(400).send(this.page('OIDC', '<p>Diese Interaktion wird noch nicht unterstuetzt.</p>'));
const page = await this.templates.renderError({
title: 'OIDC',
message: 'Diese Interaktion wird noch nicht unterstuetzt.',
});
response.status(400).send(page);
}
@Post(':uid/login')
@@ -69,9 +84,13 @@ export class OidcInteractionController {
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.')));
const page = await this.templates.renderLogin({
uid,
username,
errorMessage: 'Ungueltige Zugangsdaten.',
registrationUrl: this.registrationUrl,
});
response.status(401).send(page);
return;
}
@@ -89,122 +108,6 @@ export class OidcInteractionController {
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`;

View File

@@ -11,6 +11,7 @@ import { OidcAdminClientsController } from './oidc-admin-clients.controller';
import { OidcAdminGuard } from './oidc-admin.guard';
import { OidcClientService } from './oidc-client.service';
import { OidcInteractionController } from './oidc-interaction.controller';
import { OidcInteractionTemplateService } from './oidc-interaction-template.service';
import { OidcProviderService } from './oidc-provider.service';
@Module({
@@ -26,6 +27,6 @@ import { OidcProviderService } from './oidc-provider.service';
AuditModule,
],
controllers: [OidcAdminClientsController, OidcInteractionController],
providers: [OidcAdminGuard, OidcClientService, OidcProviderService],
providers: [OidcAdminGuard, OidcClientService, OidcInteractionTemplateService, OidcProviderService],
})
export class OidcModule {}

View File

@@ -0,0 +1,23 @@
<p class="intro">Die Anwendung <strong>{{clientName}}</strong> m&ouml;chte auf dein Konto zugreifen.</p>
{{#if redirectUri}}
<dl class="consent-details">
<div>
<dt>Weiterleitung</dt>
<dd>{{redirectUri}}</dd>
</div>
</dl>
{{/if}}
<div class="scope-list" aria-label="Angeforderte Berechtigungen">
{{#each scopes}}
<section class="scope-item">
<strong>{{name}}</strong>
<span>{{description}}</span>
</section>
{{/each}}
</div>
<form method="post" action="{{confirmAction}}">
<button type="submit">Zugriff erlauben</button>
</form>
<form method="post" action="{{abortAction}}">
<button class="secondary" type="submit">Ablehnen</button>
</form>

View File

@@ -0,0 +1 @@
<p class="intro">{{message}}</p>

View File

@@ -0,0 +1,113 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>{{title}} - {{branding.productName}}</title>
<style>
* { box-sizing: border-box; }
body {
background: #f3f6f7;
color: #18202a;
font-family: Arial, Helvetica, sans-serif;
margin: 0;
min-height: 100vh;
padding: 24px;
}
.page {
align-items: center;
display: flex;
justify-content: center;
min-height: calc(100vh - 48px);
}
main {
background: #ffffff;
border: 1px solid #d8e0e7;
border-radius: 8px;
box-shadow: 0 16px 40px rgb(24 32 42 / 8%);
max-width: 460px;
overflow: hidden;
width: 100%;
}
.brand {
align-items: center;
border-bottom: 1px solid #e7ecef;
display: flex;
gap: 12px;
min-height: 68px;
padding: 16px 28px;
}
.brand img { display: block; max-height: 36px; max-width: 180px; }
.brand-name { color: #26313d; font-size: 1rem; font-weight: 700; }
.content { padding: 28px; }
h1 { font-size: 1.45rem; letter-spacing: 0; margin: 0 0 22px; }
form { display: grid; gap: 16px; margin-top: 16px; }
label { display: grid; gap: 7px; font-weight: 700; }
input {
border: 1px solid #aebbc7;
border-radius: 6px;
color: #18202a;
font: inherit;
min-height: 44px;
padding: 10px 12px;
width: 100%;
}
input:focus { border-color: {{branding.primaryColor}}; outline: 3px solid rgb(15 107 110 / 18%); }
button {
background: {{branding.primaryColor}};
border: 1px solid {{branding.primaryColor}};
border-radius: 6px;
color: #ffffff;
cursor: pointer;
font: inherit;
font-weight: 700;
min-height: 44px;
padding: 10px 14px;
width: 100%;
}
button:hover { filter: brightness(0.92); }
button:focus-visible, a:focus-visible { outline: 3px solid rgb(15 107 110 / 25%); outline-offset: 2px; }
button.secondary { background: #ffffff; color: {{branding.primaryColor}}; }
a { color: {{branding.primaryColor}}; font-weight: 700; overflow-wrap: anywhere; text-decoration: none; }
a:hover { text-decoration: underline; }
.form-link { margin: 18px 0 0; text-align: center; }
.intro { color: #3a4551; line-height: 1.5; margin: 0 0 16px; }
.message { border-radius: 6px; line-height: 1.45; margin: 0 0 16px; padding: 12px; }
.message.error { background: #fff1f0; border: 1px solid #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; }
footer { color: #637083; font-size: 0.78rem; line-height: 1.4; padding: 0 28px 24px; text-align: center; }
@media (max-width: 520px) {
body { padding: 12px; }
.page { min-height: calc(100vh - 24px); }
.brand { padding: 14px 20px; }
.content { padding: 24px 20px; }
footer { padding: 0 20px 20px; }
}
</style>
</head>
<body>
<div class="page">
<main>
<header class="brand">
{{#if branding.logoUrl}}
<img src="{{branding.logoUrl}}" alt="{{branding.productName}}">
{{else}}
<span class="brand-name">{{branding.productName}}</span>
{{/if}}
</header>
<section class="content">
<h1>{{title}}</h1>
{{{body}}}
</section>
<footer>{{branding.companyName}}</footer>
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,18 @@
{{#if errorMessage}}
<p class="message error" role="alert">{{errorMessage}}</p>
{{/if}}
<form method="post" action="{{loginAction}}">
<label>
Benutzername
<input name="username" autocomplete="username" value="{{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="{{abortAction}}">
<button class="secondary" type="submit">Abbrechen</button>
</form>
<p class="form-link"><a href="{{registrationUrl}}">Neues Konto registrieren</a></p>