feat: dynamic SSO configuration via Admin UI

SSO config (Tenant ID, Client ID, Client Secret, Redirect URI) can now
be managed entirely from the Admin SSO page. Config is stored in Redis
(persistent) and the MSAL client is reinitialized on save — no server
restart or console access required.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-09 22:51:01 +01:00
parent eba738fdc5
commit bc156e1657
3 changed files with 763 additions and 199 deletions

View file

@ -1,6 +1,7 @@
import { import {
Injectable, Injectable,
Logger, Logger,
OnModuleInit,
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@ -11,6 +12,7 @@ import {
type AuthorizationCodeRequest, type AuthorizationCodeRequest,
type AuthenticationResult, type AuthenticationResult,
} from '@azure/msal-node'; } from '@azure/msal-node';
import { RedisService } from '../../../redis/redis.service';
/** /**
* Informationen aus dem Microsoft ID-Token. * Informationen aus dem Microsoft ID-Token.
@ -26,53 +28,184 @@ export interface MsUserInfo {
lastName: string; lastName: string;
} }
/**
* SSO-Konfiguration die in Redis gespeichert wird.
*/
export interface SsoConfig {
tenantId: string;
clientId: string;
clientSecret: string;
redirectUri: string;
}
/** Redis-Key fuer die SSO-Konfiguration (persistent, kein TTL) */
const SSO_CONFIG_KEY = 'sso_config';
/** /**
* EntraIdService - Microsoft Entra ID (Azure AD) Integration. * EntraIdService - Microsoft Entra ID (Azure AD) Integration.
* *
* Nutzt MSAL ConfidentialClientApplication fuer den * Nutzt MSAL ConfidentialClientApplication fuer den
* Authorization Code Flow (Server-seitig). * Authorization Code Flow (Server-seitig).
*
* Konfiguration wird aus Redis geladen (dynamisch via Admin-UI),
* mit Fallback auf Umgebungsvariablen.
*/ */
@Injectable() @Injectable()
export class EntraIdService { export class EntraIdService implements OnModuleInit {
private readonly logger = new Logger(EntraIdService.name); private readonly logger = new Logger(EntraIdService.name);
private msalClient: ConfidentialClientApplication | null = null; private msalClient: ConfidentialClientApplication | null = null;
private readonly redirectUri: string; private redirectUri = '';
private readonly scopes = ['openid', 'profile', 'email', 'User.Read']; private readonly scopes = ['openid', 'profile', 'email', 'User.Read'];
constructor(private readonly config: ConfigService) { constructor(
private readonly config: ConfigService,
private readonly redis: RedisService,
) {}
/**
* Beim Start: Konfiguration aus Redis laden (Fallback: Env-Vars).
*/
async onModuleInit(): Promise<void> {
// Versuche Konfiguration aus Redis zu laden
const redisConfig = await this.loadConfigFromRedis();
if (redisConfig) {
this.initializeMsal(redisConfig);
this.logger.log(
'Microsoft Entra ID SSO aus Redis-Konfiguration initialisiert',
);
return;
}
// Fallback: Umgebungsvariablen
const clientId = this.config.get<string>('AZURE_CLIENT_ID'); const clientId = this.config.get<string>('AZURE_CLIENT_ID');
const tenantId = this.config.get<string>('AZURE_TENANT_ID'); const tenantId = this.config.get<string>('AZURE_TENANT_ID');
const clientSecret = this.config.get<string>('AZURE_CLIENT_SECRET'); const clientSecret = this.config.get<string>('AZURE_CLIENT_SECRET');
this.redirectUri = this.config.get<string>( const redirectUri = this.config.get<string>('AZURE_REDIRECT_URI');
'AZURE_REDIRECT_URI',
'http://localhost/api/v1/auth/sso/microsoft/callback',
);
if (clientId && tenantId && clientSecret) { if (clientId && tenantId && clientSecret) {
const msalConfig: Configuration = { this.initializeMsal({
auth: { tenantId,
clientId, clientId,
authority: `https://login.microsoftonline.com/${tenantId}`, clientSecret,
clientSecret, redirectUri:
}, redirectUri ||
system: { 'http://localhost/api/v1/auth/sso/microsoft/callback',
loggerOptions: { });
loggerCallback: (level, message) => { this.logger.log(
this.logger.debug(`MSAL [${level}]: ${message}`); 'Microsoft Entra ID SSO aus Umgebungsvariablen initialisiert',
}, );
},
},
};
this.msalClient = new ConfidentialClientApplication(msalConfig);
this.logger.log('Microsoft Entra ID SSO konfiguriert');
} else { } else {
this.logger.warn( this.logger.warn(
'Microsoft Entra ID SSO nicht konfiguriert (AZURE_CLIENT_ID, AZURE_TENANT_ID oder AZURE_CLIENT_SECRET fehlt)', 'Microsoft Entra ID SSO nicht konfiguriert (weder Redis noch Umgebungsvariablen)',
); );
} }
} }
/**
* MSAL Client mit der gegebenen Konfiguration initialisieren.
*/
private initializeMsal(ssoConfig: SsoConfig): void {
const msalConfig: Configuration = {
auth: {
clientId: ssoConfig.clientId,
authority: `https://login.microsoftonline.com/${ssoConfig.tenantId}`,
clientSecret: ssoConfig.clientSecret,
},
system: {
loggerOptions: {
loggerCallback: (level, message) => {
this.logger.debug(`MSAL [${level}]: ${message}`);
},
},
},
};
this.msalClient = new ConfidentialClientApplication(msalConfig);
this.redirectUri = ssoConfig.redirectUri;
}
/**
* Konfiguration aus Redis laden.
*/
private async loadConfigFromRedis(): Promise<SsoConfig | null> {
try {
const raw = await this.redis.get(SSO_CONFIG_KEY);
if (!raw) return null;
const config = JSON.parse(raw) as SsoConfig;
if (config.tenantId && config.clientId && config.clientSecret) {
return config;
}
return null;
} catch (err) {
this.logger.warn(
`Fehler beim Laden der SSO-Config aus Redis: ${(err as Error).message}`,
);
return null;
}
}
/**
* Konfiguration speichern und MSAL Client neu initialisieren.
* Wird vom Admin-UI aufgerufen.
*/
async reconfigure(ssoConfig: SsoConfig): Promise<void> {
// In Redis speichern (persistent, kein TTL)
await this.redis.set(SSO_CONFIG_KEY, JSON.stringify(ssoConfig));
// MSAL Client neu initialisieren
this.initializeMsal(ssoConfig);
this.logger.log('Microsoft Entra ID SSO neu konfiguriert via Admin-UI');
}
/**
* Aktuelle Konfiguration lesen (Secret wird maskiert).
*/
async getConfig(): Promise<
| (Omit<SsoConfig, 'clientSecret'> & { clientSecretMasked: string })
| null
> {
// Zuerst aus Redis
const redisConfig = await this.loadConfigFromRedis();
if (redisConfig) {
return {
tenantId: redisConfig.tenantId,
clientId: redisConfig.clientId,
redirectUri: redisConfig.redirectUri,
clientSecretMasked: this.maskSecret(redisConfig.clientSecret),
};
}
// Fallback: Env-Vars
const clientId = this.config.get<string>('AZURE_CLIENT_ID');
const tenantId = this.config.get<string>('AZURE_TENANT_ID');
const clientSecret = this.config.get<string>('AZURE_CLIENT_SECRET');
const redirectUri = this.config.get<string>('AZURE_REDIRECT_URI');
if (clientId && tenantId && clientSecret) {
return {
tenantId,
clientId,
redirectUri:
redirectUri ||
'http://localhost/api/v1/auth/sso/microsoft/callback',
clientSecretMasked: this.maskSecret(clientSecret),
};
}
return null;
}
/**
* Secret maskieren: nur die letzten 4 Zeichen anzeigen.
*/
private maskSecret(secret: string): string {
if (secret.length <= 4) return '****';
return '****' + secret.slice(-4);
}
/** /**
* Ist Entra ID SSO konfiguriert? * Ist Entra ID SSO konfiguriert?
*/ */

View file

@ -1,20 +1,25 @@
import { import {
Controller, Controller,
Get, Get,
Post,
Body,
Query, Query,
Res, Res,
Logger, Logger,
ServiceUnavailableException, ServiceUnavailableException,
UnauthorizedException, UnauthorizedException,
InternalServerErrorException, UseGuards,
BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Response } from 'express'; import { Response } from 'express';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Public } from '../../../common/decorators/public.decorator'; import { Public } from '../../../common/decorators/public.decorator';
import { Roles } from '../../../common/decorators/roles.decorator';
import { RolesGuard } from '../../../common/guards/roles.guard';
import { RedisService } from '../../../redis/redis.service'; import { RedisService } from '../../../redis/redis.service';
import { EntraIdService } from './entra-id.service'; import { EntraIdService, type SsoConfig } from './entra-id.service';
import { AuthService } from '../auth.service'; import { AuthService } from '../auth.service';
/** /**
@ -23,6 +28,10 @@ import { AuthService } from '../auth.service';
* Flow: * Flow:
* 1. GET /auth/sso/microsoft Redirect zu Microsoft Login * 1. GET /auth/sso/microsoft Redirect zu Microsoft Login
* 2. GET /auth/sso/microsoft/callback Callback von Microsoft, User anlegen/verknuepfen, JWT generieren * 2. GET /auth/sso/microsoft/callback Callback von Microsoft, User anlegen/verknuepfen, JWT generieren
*
* Admin:
* - GET /auth/sso/config Aktuelle Konfiguration lesen (Secret maskiert)
* - POST /auth/sso/config Konfiguration speichern und MSAL neu initialisieren
*/ */
@ApiTags('SSO') @ApiTags('SSO')
@Controller('auth/sso') @Controller('auth/sso')
@ -36,6 +45,83 @@ export class SsoController {
private readonly config: ConfigService, private readonly config: ConfigService,
) {} ) {}
/**
* GET /api/v1/auth/sso/config
* Aktuelle SSO-Konfiguration lesen (nur PLATFORM_ADMIN).
* Client Secret wird maskiert zurueckgegeben.
*/
@Get('config')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'SSO-Konfiguration lesen (Admin)' })
async getConfig() {
const config = await this.entraIdService.getConfig();
return {
configured: this.entraIdService.isConfigured(),
config: config || null,
};
}
/**
* POST /api/v1/auth/sso/config
* SSO-Konfiguration speichern und MSAL Client neu initialisieren (nur PLATFORM_ADMIN).
*/
@Post('config')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'SSO-Konfiguration speichern (Admin)' })
async saveConfig(
@Body()
body: {
tenantId: string;
clientId: string;
clientSecret?: string;
redirectUri: string;
},
) {
// Validierung
if (!body.tenantId || !body.clientId || !body.redirectUri) {
throw new BadRequestException(
'Tenant ID, Client ID und Redirect URI sind erforderlich',
);
}
// Wenn kein neues Secret: bestehendes aus Redis laden
let clientSecret = body.clientSecret;
if (!clientSecret) {
const existing = await this.entraIdService.getConfig();
// Wir brauchen das echte Secret aus Redis
const raw = await this.redis.get('sso_config');
if (raw) {
const parsed = JSON.parse(raw) as SsoConfig;
clientSecret = parsed.clientSecret;
}
}
if (!clientSecret) {
throw new BadRequestException(
'Client Secret ist erforderlich (kein bestehendes Secret vorhanden)',
);
}
const ssoConfig: SsoConfig = {
tenantId: body.tenantId.trim(),
clientId: body.clientId.trim(),
clientSecret: clientSecret.trim(),
redirectUri: body.redirectUri.trim(),
};
await this.entraIdService.reconfigure(ssoConfig);
this.logger.log('SSO-Konfiguration via Admin-UI aktualisiert');
return {
success: true,
message: 'SSO-Konfiguration gespeichert und aktiviert',
configured: true,
};
}
/** /**
* GET /api/v1/auth/sso/microsoft * GET /api/v1/auth/sso/microsoft
* Initiiert den OAuth2 Authorization Code Flow. * Initiiert den OAuth2 Authorization Code Flow.

View file

@ -1,10 +1,21 @@
import { useQuery } from '@tanstack/react-query'; import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client'; import api from '../api/client';
interface SsoStatus { interface SsoStatus {
microsoft: boolean; microsoft: boolean;
} }
interface SsoConfigResponse {
configured: boolean;
config: {
tenantId: string;
clientId: string;
redirectUri: string;
clientSecretMasked: string;
} | null;
}
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
background: 'var(--color-bg-card)', background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)', borderRadius: 'var(--radius-md)',
@ -14,19 +25,6 @@ const cardStyle: React.CSSProperties = {
marginBottom: '1.5rem', marginBottom: '1.5rem',
}; };
const codeBlockStyle: React.CSSProperties = {
background: '#1e293b',
color: '#e2e8f0',
borderRadius: 'var(--radius-sm)',
padding: '1rem 1.25rem',
fontSize: '0.8125rem',
fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
lineHeight: 1.7,
overflowX: 'auto',
whiteSpace: 'pre',
marginTop: '0.75rem',
};
const inlineCodeStyle: React.CSSProperties = { const inlineCodeStyle: React.CSSProperties = {
background: 'var(--color-bg)', background: 'var(--color-bg)',
padding: '0.125rem 0.375rem', padding: '0.125rem 0.375rem',
@ -98,8 +96,37 @@ const tdStyle: React.CSSProperties = {
verticalAlign: 'top', verticalAlign: 'top',
}; };
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.625rem 0.75rem',
fontSize: '0.875rem',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg-card)',
color: 'var(--color-text)',
fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
outline: 'none',
boxSizing: 'border-box' as const,
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 600,
color: 'var(--color-text)',
marginBottom: '0.375rem',
};
const hintStyle: React.CSSProperties = {
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
marginTop: '0.25rem',
};
export function AdminSsoPage() { export function AdminSsoPage() {
const { data: ssoStatus, isLoading } = useQuery<SsoStatus>({ const queryClient = useQueryClient();
const { data: ssoStatus, isLoading: statusLoading } = useQuery<SsoStatus>({
queryKey: ['sso', 'status'], queryKey: ['sso', 'status'],
queryFn: async () => { queryFn: async () => {
const res = await api.get<SsoStatus>('/auth/sso/status'); const res = await api.get<SsoStatus>('/auth/sso/status');
@ -107,48 +134,156 @@ export function AdminSsoPage() {
}, },
}); });
const redirectUri = `${window.location.origin}/api/v1/auth/sso/microsoft/callback`; const { data: ssoConfig, isLoading: configLoading } =
useQuery<SsoConfigResponse>({
queryKey: ['sso', 'config'],
queryFn: async () => {
const res = await api.get<SsoConfigResponse>('/auth/sso/config');
return res.data;
},
});
// Formular-State
const [tenantId, setTenantId] = useState('');
const [clientId, setClientId] = useState('');
const [clientSecret, setClientSecret] = useState('');
const [redirectUri, setRedirectUri] = useState('');
const [saveSuccess, setSaveSuccess] = useState(false);
// Default Redirect-URI
const defaultRedirectUri = `${window.location.origin}/api/v1/auth/sso/microsoft/callback`;
// Formular mit bestehenden Werten befuellen
useEffect(() => {
if (ssoConfig?.config) {
setTenantId(ssoConfig.config.tenantId);
setClientId(ssoConfig.config.clientId);
setRedirectUri(ssoConfig.config.redirectUri);
// Secret bleibt leer — wird nur bei Aenderung gesendet
} else {
// Default Redirect-URI setzen wenn noch keine Config da
setRedirectUri(defaultRedirectUri);
}
}, [ssoConfig, defaultRedirectUri]);
const saveMutation = useMutation({
mutationFn: async (data: {
tenantId: string;
clientId: string;
clientSecret?: string;
redirectUri: string;
}) => {
const res = await api.post('/auth/sso/config', data);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sso', 'status'] });
queryClient.invalidateQueries({ queryKey: ['sso', 'config'] });
setClientSecret(''); // Secret-Feld leeren
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 4000);
},
});
const handleSave = () => {
const data: {
tenantId: string;
clientId: string;
clientSecret?: string;
redirectUri: string;
} = {
tenantId,
clientId,
redirectUri,
};
// Secret nur mitsenden wenn es geaendert wurde
if (clientSecret) {
data.clientSecret = clientSecret;
}
saveMutation.mutate(data);
};
const isLoading = statusLoading || configLoading;
const hasExistingConfig = !!ssoConfig?.config;
const canSave =
tenantId.trim() &&
clientId.trim() &&
redirectUri.trim() &&
(clientSecret.trim() || hasExistingConfig); // Secret nur noetig bei Erstconfig
return ( return (
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}> <div
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>SSO-Konfiguration</h1> style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
}}
>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>
SSO-Konfiguration
</h1>
</div> </div>
{/* Status-Anzeige */} {/* Status-Anzeige */}
<div style={cardStyle}> <div style={cardStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}> <div
<svg width="24" height="24" viewBox="0 0 21 21" xmlns="http://www.w3.org/2000/svg"> style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
marginBottom: '0.5rem',
}}
>
<svg
width="24"
height="24"
viewBox="0 0 21 21"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="1" y="1" width="9" height="9" fill="#f25022" /> <rect x="1" y="1" width="9" height="9" fill="#f25022" />
<rect x="11" y="1" width="9" height="9" fill="#7fba00" /> <rect x="11" y="1" width="9" height="9" fill="#7fba00" />
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" /> <rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
<rect x="11" y="11" width="9" height="9" fill="#ffb900" /> <rect x="11" y="11" width="9" height="9" fill="#ffb900" />
</svg> </svg>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600 }}>Microsoft Entra ID (Azure AD)</h2> <h2 style={{ fontSize: '1.125rem', fontWeight: 600 }}>
Microsoft Entra ID (Azure AD)
</h2>
</div> </div>
<div style={{ <div
display: 'flex', style={{
alignItems: 'center', display: 'flex',
gap: '0.5rem', alignItems: 'center',
padding: '0.75rem 1rem', gap: '0.5rem',
borderRadius: 'var(--radius-sm)', padding: '0.75rem 1rem',
background: ssoStatus?.microsoft ? '#f0fdf4' : '#fef2f2', borderRadius: 'var(--radius-sm)',
border: `1px solid ${ssoStatus?.microsoft ? '#bbf7d0' : '#fecaca'}`, background: ssoStatus?.microsoft ? '#f0fdf4' : '#fef2f2',
marginTop: '0.75rem', border: `1px solid ${ssoStatus?.microsoft ? '#bbf7d0' : '#fecaca'}`,
}}> marginTop: '0.75rem',
<span style={{ }}
display: 'inline-block', >
width: 10, <span
height: 10, style={{
borderRadius: '50%', display: 'inline-block',
background: ssoStatus?.microsoft ? 'var(--color-success)' : 'var(--color-error)', width: 10,
}} /> height: 10,
<span style={{ borderRadius: '50%',
fontSize: '0.875rem', background: ssoStatus?.microsoft
fontWeight: 500, ? 'var(--color-success)'
color: ssoStatus?.microsoft ? '#166534' : '#991b1b', : 'var(--color-error)',
}}> }}
/>
<span
style={{
fontSize: '0.875rem',
fontWeight: 500,
color: ssoStatus?.microsoft ? '#166534' : '#991b1b',
}}
>
{isLoading {isLoading
? 'Status wird geladen...' ? 'Status wird geladen...'
: ssoStatus?.microsoft : ssoStatus?.microsoft
@ -160,7 +295,13 @@ export function AdminSsoPage() {
{/* Einrichtungs-Anleitung */} {/* Einrichtungs-Anleitung */}
<div style={cardStyle}> <div style={cardStyle}>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, marginBottom: '1.25rem' }}> <h2
style={{
fontSize: '1.125rem',
fontWeight: 600,
marginBottom: '1.25rem',
}}
>
Einrichtungsanleitung Einrichtungsanleitung
</h2> </h2>
@ -175,11 +316,14 @@ export function AdminSsoPage() {
href="https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade" href="https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ color: 'var(--color-primary)', textDecoration: 'underline' }} style={{
color: 'var(--color-primary)',
textDecoration: 'underline',
}}
> >
Azure Portal &rarr; App registrations Azure Portal &rarr; App registrations
</a> </a>{' '}
{' '}und klicke auf <strong>New registration</strong>. und klicke auf <strong>New registration</strong>.
</p> </p>
<table style={tableStyle}> <table style={tableStyle}>
<thead> <thead>
@ -190,23 +334,37 @@ export function AdminSsoPage() {
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td style={tdStyle}><strong>Name</strong></td> <td style={tdStyle}>
<strong>Name</strong>
</td>
<td style={tdStyle}>INSIGHT Platform</td> <td style={tdStyle}>INSIGHT Platform</td>
</tr> </tr>
<tr> <tr>
<td style={tdStyle}><strong>Supported account types</strong></td>
<td style={tdStyle}> <td style={tdStyle}>
Accounts in this organizational directory only<br /> <strong>Supported account types</strong>
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}> </td>
<td style={tdStyle}>
Accounts in this organizational directory only
<br />
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
}}
>
(Single tenant nur euer Azure AD) (Single tenant nur euer Azure AD)
</span> </span>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style={tdStyle}><strong>Redirect URI</strong></td>
<td style={tdStyle}> <td style={tdStyle}>
Platform: <strong>Web</strong><br /> <strong>Redirect URI</strong>
URI: <code style={inlineCodeStyle}>{redirectUri}</code> </td>
<td style={tdStyle}>
Platform: <strong>Web</strong>
<br />
URI:{' '}
<code style={inlineCodeStyle}>{defaultRedirectUri}</code>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -220,7 +378,9 @@ export function AdminSsoPage() {
<div style={stepContentStyle}> <div style={stepContentStyle}>
<h3 style={h3Style}>Client Secret erstellen</h3> <h3 style={h3Style}>Client Secret erstellen</h3>
<p style={pStyle}> <p style={pStyle}>
In der App Registration &rarr; <strong>Certificates &amp; secrets</strong> &rarr; <strong>New client secret</strong>. In der App Registration &rarr;{' '}
<strong>Certificates &amp; secrets</strong> &rarr;{' '}
<strong>New client secret</strong>.
</p> </p>
<table style={tableStyle}> <table style={tableStyle}>
<thead> <thead>
@ -231,30 +391,43 @@ export function AdminSsoPage() {
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td style={tdStyle}><strong>Description</strong></td> <td style={tdStyle}>
<strong>Description</strong>
</td>
<td style={tdStyle}>INSIGHT Platform SSO</td> <td style={tdStyle}>INSIGHT Platform SSO</td>
</tr> </tr>
<tr> <tr>
<td style={tdStyle}><strong>Expires</strong></td>
<td style={tdStyle}> <td style={tdStyle}>
24 months (empfohlen)<br /> <strong>Expires</strong>
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}> </td>
<td style={tdStyle}>
24 months (empfohlen)
<br />
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
}}
>
Danach muss das Secret erneuert werden Danach muss das Secret erneuert werden
</span> </span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div style={{ <div
marginTop: '0.75rem', style={{
padding: '0.625rem 0.75rem', marginTop: '0.75rem',
background: '#fffbeb', padding: '0.625rem 0.75rem',
border: '1px solid #fde68a', background: '#fffbeb',
borderRadius: 'var(--radius-sm)', border: '1px solid #fde68a',
fontSize: '0.8125rem', borderRadius: 'var(--radius-sm)',
color: '#92400e', fontSize: '0.8125rem',
}}> color: '#92400e',
<strong>Wichtig:</strong> Den <strong>Value</strong> des Secrets sofort kopieren er wird nur einmalig angezeigt! }}
>
<strong>Wichtig:</strong> Den <strong>Value</strong> des Secrets
sofort kopieren er wird nur einmalig angezeigt!
</div> </div>
</div> </div>
</div> </div>
@ -265,7 +438,11 @@ export function AdminSsoPage() {
<div style={stepContentStyle}> <div style={stepContentStyle}>
<h3 style={h3Style}>API Permissions konfigurieren</h3> <h3 style={h3Style}>API Permissions konfigurieren</h3>
<p style={pStyle}> <p style={pStyle}>
In der App Registration &rarr; <strong>API permissions</strong> &rarr; <strong>Add a permission</strong> &rarr; <strong>Microsoft Graph</strong> &rarr; <strong>Delegated permissions</strong>. In der App Registration &rarr;{' '}
<strong>API permissions</strong> &rarr;{' '}
<strong>Add a permission</strong> &rarr;{' '}
<strong>Microsoft Graph</strong> &rarr;{' '}
<strong>Delegated permissions</strong>.
</p> </p>
<p style={pStyle}>Folgende Berechtigungen hinzufügen:</p> <p style={pStyle}>Folgende Berechtigungen hinzufügen:</p>
<table style={tableStyle}> <table style={tableStyle}>
@ -278,133 +455,261 @@ export function AdminSsoPage() {
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td style={tdStyle}><code style={inlineCodeStyle}>openid</code></td> <td style={tdStyle}>
<code style={inlineCodeStyle}>openid</code>
</td>
<td style={tdStyle}>Delegated</td> <td style={tdStyle}>Delegated</td>
<td style={tdStyle}>Sign users in</td> <td style={tdStyle}>Sign users in</td>
</tr> </tr>
<tr> <tr>
<td style={tdStyle}><code style={inlineCodeStyle}>profile</code></td> <td style={tdStyle}>
<code style={inlineCodeStyle}>profile</code>
</td>
<td style={tdStyle}>Delegated</td> <td style={tdStyle}>Delegated</td>
<td style={tdStyle}>View users' basic profile</td> <td style={tdStyle}>View users' basic profile</td>
</tr> </tr>
<tr> <tr>
<td style={tdStyle}><code style={inlineCodeStyle}>email</code></td> <td style={tdStyle}>
<code style={inlineCodeStyle}>email</code>
</td>
<td style={tdStyle}>Delegated</td> <td style={tdStyle}>Delegated</td>
<td style={tdStyle}>View users' email address</td> <td style={tdStyle}>View users' email address</td>
</tr> </tr>
<tr> <tr>
<td style={tdStyle}><code style={inlineCodeStyle}>User.Read</code></td> <td style={tdStyle}>
<code style={inlineCodeStyle}>User.Read</code>
</td>
<td style={tdStyle}>Delegated</td> <td style={tdStyle}>Delegated</td>
<td style={tdStyle}>Sign in and read user profile</td> <td style={tdStyle}>Sign in and read user profile</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<p style={{ ...pStyle, marginTop: '0.5rem' }}> <p style={{ ...pStyle, marginTop: '0.5rem' }}>
Dann <strong>Grant admin consent</strong> klicken, um die Berechtigungen für alle Benutzer freizugeben. Dann <strong>Grant admin consent</strong> klicken, um die
Berechtigungen für alle Benutzer freizugeben.
</p> </p>
</div> </div>
</div> </div>
{/* Schritt 4 */} {/* Schritt 4 — Konfiguration eingeben und speichern */}
<div style={stepStyle}> <div style={stepStyle}>
<div style={stepNumberStyle}>4</div> <div style={stepNumberStyle}>4</div>
<div style={stepContentStyle}> <div style={stepContentStyle}>
<h3 style={h3Style}>Werte aus Azure Portal kopieren</h3> <h3 style={h3Style}>
Werte aus Azure Portal hier eintragen und speichern
</h3>
<p style={pStyle}> <p style={pStyle}>
In der App Registration &rarr; <strong>Overview</strong> findest du die benötigten Werte: Kopiere die Werte aus der App Registration &rarr;{' '}
<strong>Overview</strong> und trage sie in die Felder ein. Beim
Speichern wird die SSO-Verbindung automatisch aktiviert.
</p> </p>
<table style={tableStyle}>
<thead>
<tr>
<th style={thStyle}>Azure Portal Feld</th>
<th style={thStyle}>Umgebungsvariable</th>
</tr>
</thead>
<tbody>
<tr>
<td style={tdStyle}><strong>Directory (tenant) ID</strong></td>
<td style={tdStyle}><code style={inlineCodeStyle}>AZURE_TENANT_ID</code></td>
</tr>
<tr>
<td style={tdStyle}><strong>Application (client) ID</strong></td>
<td style={tdStyle}><code style={inlineCodeStyle}>AZURE_CLIENT_ID</code></td>
</tr>
<tr>
<td style={tdStyle}><strong>Client secret value</strong> (aus Schritt 2)</td>
<td style={tdStyle}><code style={inlineCodeStyle}>AZURE_CLIENT_SECRET</code></td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Schritt 5 */} <div
<div style={stepStyle}> style={{
<div style={stepNumberStyle}>5</div> display: 'flex',
<div style={stepContentStyle}> flexDirection: 'column',
<h3 style={h3Style}>Umgebungsvariablen auf dem Server setzen</h3> gap: '1rem',
<p style={pStyle}> marginTop: '1rem',
Trage die Werte in die <code style={inlineCodeStyle}>.env</code>-Datei auf dem Server ein: }}
</p> >
<div style={codeBlockStyle}> {/* Tenant ID */}
{`AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx <div>
AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx <label style={labelStyle}>
AZURE_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx Directory (Tenant) ID{' '}
AZURE_REDIRECT_URI=${redirectUri}`} <span style={{ color: 'var(--color-error)' }}>*</span>
</div> </label>
</div> <input
</div> type="text"
value={tenantId}
onChange={(e) => setTenantId(e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
style={inputStyle}
/>
<div style={hintStyle}>
Azure Portal &rarr; App Registration &rarr; Overview &rarr;
Directory (tenant) ID
</div>
</div>
{/* Schritt 6 */} {/* Client ID */}
<div style={stepStyle}> <div>
<div style={stepNumberStyle}>6</div> <label style={labelStyle}>
<div style={stepContentStyle}> Application (Client) ID{' '}
<h3 style={h3Style}>Core-Service neu starten</h3> <span style={{ color: 'var(--color-error)' }}>*</span>
<p style={pStyle}> </label>
Nach dem Setzen der Umgebungsvariablen den Backend-Service neu starten: <input
</p> type="text"
<div style={codeBlockStyle}> value={clientId}
{`docker compose restart core`} onChange={(e) => setClientId(e.target.value)}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
style={inputStyle}
/>
<div style={hintStyle}>
Azure Portal &rarr; App Registration &rarr; Overview &rarr;
Application (client) ID
</div>
</div>
{/* Client Secret */}
<div>
<label style={labelStyle}>
Client Secret{' '}
{!hasExistingConfig && (
<span style={{ color: 'var(--color-error)' }}>*</span>
)}
</label>
<input
type="password"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={
hasExistingConfig
? `Gespeichert (${ssoConfig?.config?.clientSecretMasked}) — leer lassen um beizubehalten`
: 'Client Secret Value eingeben'
}
style={inputStyle}
/>
<div style={hintStyle}>
Azure Portal &rarr; App Registration &rarr; Certificates &amp;
secrets &rarr; Client secret Value
{hasExistingConfig && (
<span style={{ color: 'var(--color-text-secondary)' }}>
{' '}
Leer lassen, um das bestehende Secret zu behalten
</span>
)}
</div>
</div>
{/* Redirect URI */}
<div>
<label style={labelStyle}>
Redirect URI{' '}
<span style={{ color: 'var(--color-error)' }}>*</span>
</label>
<input
type="text"
value={redirectUri}
onChange={(e) => setRedirectUri(e.target.value)}
placeholder={defaultRedirectUri}
style={inputStyle}
/>
<div style={hintStyle}>
Muss exakt mit der Redirect URI in der Azure App Registration
übereinstimmen
</div>
</div>
{/* Speichern-Button + Status */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
marginTop: '0.5rem',
}}
>
<button
onClick={handleSave}
disabled={!canSave || saveMutation.isPending}
style={{
padding: '0.625rem 1.5rem',
background: canSave ? 'var(--color-primary)' : '#94a3b8',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: canSave ? 'pointer' : 'not-allowed',
opacity: saveMutation.isPending ? 0.7 : 1,
}}
>
{saveMutation.isPending
? 'Wird gespeichert...'
: 'Speichern und aktivieren'}
</button>
{saveSuccess && (
<span
style={{
fontSize: '0.875rem',
color: '#166534',
fontWeight: 500,
}}
>
SSO-Konfiguration gespeichert und aktiviert!
</span>
)}
{saveMutation.isError && (
<span
style={{
fontSize: '0.875rem',
color: 'var(--color-error)',
fontWeight: 500,
}}
>
Fehler:{' '}
{(saveMutation.error as Error)?.message ||
'Konfiguration konnte nicht gespeichert werden'}
</span>
)}
</div>
</div> </div>
<p style={{ ...pStyle, marginTop: '0.75rem' }}>
Nach dem Neustart sollte der Status oben auf{' '}
<strong style={{ color: '#166534' }}>aktiv</strong> wechseln und
der Mit Microsoft anmelden"-Button auf der Login-Seite erscheinen.
</p>
</div> </div>
</div> </div>
</div> </div>
{/* Funktionsweise */} {/* Funktionsweise */}
<div style={cardStyle}> <div style={cardStyle}>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, marginBottom: '1rem' }}> <h2
style={{
fontSize: '1.125rem',
fontWeight: 600,
marginBottom: '1rem',
}}
>
Funktionsweise Funktionsweise
</h2> </h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}> <div
<div style={{ style={{
padding: '1rem', display: 'grid',
background: 'var(--color-bg)', gridTemplateColumns: '1fr 1fr',
borderRadius: 'var(--radius-sm)', gap: '1rem',
}}> }}
>
<div
style={{
padding: '1rem',
background: 'var(--color-bg)',
borderRadius: 'var(--radius-sm)',
}}
>
<h3 style={{ ...h3Style, fontSize: '0.875rem' }}>Erstanmeldung</h3> <h3 style={{ ...h3Style, fontSize: '0.875rem' }}>Erstanmeldung</h3>
<p style={{ ...pStyle, fontSize: '0.8125rem', marginBottom: 0 }}> <p
style={{ ...pStyle, fontSize: '0.8125rem', marginBottom: 0 }}
>
Wenn ein Benutzer sich zum ersten Mal mit Microsoft anmeldet, Wenn ein Benutzer sich zum ersten Mal mit Microsoft anmeldet,
wird automatisch ein Konto angelegt (Rolle: Benutzer). wird automatisch ein Konto angelegt (Rolle: Benutzer). Existiert
Existiert bereits ein Konto mit derselben E-Mail-Adresse, bereits ein Konto mit derselben E-Mail-Adresse, wird das
wird das Microsoft-Konto automatisch verknüpft. Microsoft-Konto automatisch verknüpft.
</p> </p>
</div> </div>
<div style={{ <div
padding: '1rem', style={{
background: 'var(--color-bg)', padding: '1rem',
borderRadius: 'var(--radius-sm)', background: 'var(--color-bg)',
}}> borderRadius: 'var(--radius-sm)',
}}
>
<h3 style={{ ...h3Style, fontSize: '0.875rem' }}>Sicherheit</h3> <h3 style={{ ...h3Style, fontSize: '0.875rem' }}>Sicherheit</h3>
<p style={{ ...pStyle, fontSize: '0.8125rem', marginBottom: 0 }}> <p
Der SSO-Flow nutzt den OAuth2 Authorization Code Flow. style={{ ...pStyle, fontSize: '0.8125rem', marginBottom: 0 }}
Das Client Secret verlässt nie den Server. >
CSRF-Schutz via State-Parameter. Der SSO-Flow nutzt den OAuth2 Authorization Code Flow. Das Client
Die Multi-Faktor-Authentifizierung (MFA) von Microsoft wird unterstützt. Secret verlässt nie den Server. CSRF-Schutz via State-Parameter.
Die Multi-Faktor-Authentifizierung (MFA) von Microsoft wird
unterstützt.
</p> </p>
</div> </div>
</div> </div>
@ -412,7 +717,13 @@ AZURE_REDIRECT_URI=${redirectUri}`}
{/* Technische Details */} {/* Technische Details */}
<div style={cardStyle}> <div style={cardStyle}>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, marginBottom: '1rem' }}> <h2
style={{
fontSize: '1.125rem',
fontWeight: 600,
marginBottom: '1rem',
}}
>
Technische Referenz Technische Referenz
</h2> </h2>
<table style={tableStyle}> <table style={tableStyle}>
@ -424,34 +735,68 @@ AZURE_REDIRECT_URI=${redirectUri}`}
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td style={tdStyle}><code style={inlineCodeStyle}>GET /api/v1/auth/sso/microsoft</code></td> <td style={tdStyle}>
<td style={tdStyle}>Startet den SSO-Flow (Redirect zu Microsoft)</td> <code style={inlineCodeStyle}>
GET /api/v1/auth/sso/microsoft
</code>
</td>
<td style={tdStyle}>
Startet den SSO-Flow (Redirect zu Microsoft)
</td>
</tr> </tr>
<tr> <tr>
<td style={tdStyle}><code style={inlineCodeStyle}>GET /api/v1/auth/sso/microsoft/callback</code></td> <td style={tdStyle}>
<td style={tdStyle}>Callback von Microsoft (Token-Exchange + User-Provisioning)</td> <code style={inlineCodeStyle}>
GET /api/v1/auth/sso/microsoft/callback
</code>
</td>
<td style={tdStyle}>
Callback von Microsoft (Token-Exchange + User-Provisioning)
</td>
</tr> </tr>
<tr> <tr>
<td style={tdStyle}><code style={inlineCodeStyle}>GET /api/v1/auth/sso/status</code></td> <td style={tdStyle}>
<td style={tdStyle}>Prüft ob SSO konfiguriert ist (für Login-Seite)</td> <code style={inlineCodeStyle}>
GET /api/v1/auth/sso/status
</code>
</td>
<td style={tdStyle}>
Prüft ob SSO konfiguriert ist (für Login-Seite)
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<h3 style={{ ...h3Style, marginTop: '1.25rem', fontSize: '0.9375rem' }}>Redirect URI</h3> <h3
style={{ ...h3Style, marginTop: '1.25rem', fontSize: '0.9375rem' }}
>
Redirect URI
</h3>
<p style={pStyle}> <p style={pStyle}>
Diese URI muss exakt so in der Azure App Registration eingetragen sein: Diese URI muss exakt so in der Azure App Registration eingetragen
sein:
</p> </p>
<div style={{ <div
...codeBlockStyle, style={{
display: 'flex', background: '#1e293b',
alignItems: 'center', color: '#e2e8f0',
justifyContent: 'space-between', borderRadius: 'var(--radius-sm)',
gap: '1rem', padding: '1rem 1.25rem',
}}> fontSize: '0.8125rem',
<span>{redirectUri}</span> fontFamily:
"'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
lineHeight: 1.7,
overflowX: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
marginTop: '0.75rem',
}}
>
<span>{defaultRedirectUri}</span>
<button <button
onClick={() => navigator.clipboard.writeText(redirectUri)} onClick={() => navigator.clipboard.writeText(defaultRedirectUri)}
style={{ style={{
padding: '0.25rem 0.5rem', padding: '0.25rem 0.5rem',
background: '#334155', background: '#334155',