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 {
Injectable,
Logger,
OnModuleInit,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@ -11,6 +12,7 @@ import {
type AuthorizationCodeRequest,
type AuthenticationResult,
} from '@azure/msal-node';
import { RedisService } from '../../../redis/redis.service';
/**
* Informationen aus dem Microsoft ID-Token.
@ -26,53 +28,184 @@ export interface MsUserInfo {
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.
*
* Nutzt MSAL ConfidentialClientApplication fuer den
* Authorization Code Flow (Server-seitig).
*
* Konfiguration wird aus Redis geladen (dynamisch via Admin-UI),
* mit Fallback auf Umgebungsvariablen.
*/
@Injectable()
export class EntraIdService {
export class EntraIdService implements OnModuleInit {
private readonly logger = new Logger(EntraIdService.name);
private msalClient: ConfidentialClientApplication | null = null;
private readonly redirectUri: string;
private redirectUri = '';
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 tenantId = this.config.get<string>('AZURE_TENANT_ID');
const clientSecret = this.config.get<string>('AZURE_CLIENT_SECRET');
this.redirectUri = this.config.get<string>(
'AZURE_REDIRECT_URI',
'http://localhost/api/v1/auth/sso/microsoft/callback',
);
const redirectUri = this.config.get<string>('AZURE_REDIRECT_URI');
if (clientId && tenantId && clientSecret) {
const msalConfig: Configuration = {
auth: {
clientId,
authority: `https://login.microsoftonline.com/${tenantId}`,
clientSecret,
},
system: {
loggerOptions: {
loggerCallback: (level, message) => {
this.logger.debug(`MSAL [${level}]: ${message}`);
},
},
},
};
this.msalClient = new ConfidentialClientApplication(msalConfig);
this.logger.log('Microsoft Entra ID SSO konfiguriert');
this.initializeMsal({
tenantId,
clientId,
clientSecret,
redirectUri:
redirectUri ||
'http://localhost/api/v1/auth/sso/microsoft/callback',
});
this.logger.log(
'Microsoft Entra ID SSO aus Umgebungsvariablen initialisiert',
);
} else {
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?
*/

View file

@ -1,20 +1,25 @@
import {
Controller,
Get,
Post,
Body,
Query,
Res,
Logger,
ServiceUnavailableException,
UnauthorizedException,
InternalServerErrorException,
UseGuards,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import { Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
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 { EntraIdService } from './entra-id.service';
import { EntraIdService, type SsoConfig } from './entra-id.service';
import { AuthService } from '../auth.service';
/**
@ -23,6 +28,10 @@ import { AuthService } from '../auth.service';
* Flow:
* 1. GET /auth/sso/microsoft Redirect zu Microsoft Login
* 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')
@Controller('auth/sso')
@ -36,6 +45,83 @@ export class SsoController {
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
* 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';
interface SsoStatus {
microsoft: boolean;
}
interface SsoConfigResponse {
configured: boolean;
config: {
tenantId: string;
clientId: string;
redirectUri: string;
clientSecretMasked: string;
} | null;
}
const cardStyle: React.CSSProperties = {
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
@ -14,19 +25,6 @@ const cardStyle: React.CSSProperties = {
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 = {
background: 'var(--color-bg)',
padding: '0.125rem 0.375rem',
@ -98,8 +96,37 @@ const tdStyle: React.CSSProperties = {
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() {
const { data: ssoStatus, isLoading } = useQuery<SsoStatus>({
const queryClient = useQueryClient();
const { data: ssoStatus, isLoading: statusLoading } = useQuery<SsoStatus>({
queryKey: ['sso', 'status'],
queryFn: async () => {
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 (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>SSO-Konfiguration</h1>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
}}
>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>
SSO-Konfiguration
</h1>
</div>
{/* Status-Anzeige */}
<div style={cardStyle}>
<div 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">
<div
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="11" y="1" width="9" height="9" fill="#7fba00" />
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
</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 style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.75rem 1rem',
borderRadius: 'var(--radius-sm)',
background: ssoStatus?.microsoft ? '#f0fdf4' : '#fef2f2',
border: `1px solid ${ssoStatus?.microsoft ? '#bbf7d0' : '#fecaca'}`,
marginTop: '0.75rem',
}}>
<span style={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
background: ssoStatus?.microsoft ? 'var(--color-success)' : 'var(--color-error)',
}} />
<span style={{
fontSize: '0.875rem',
fontWeight: 500,
color: ssoStatus?.microsoft ? '#166534' : '#991b1b',
}}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.75rem 1rem',
borderRadius: 'var(--radius-sm)',
background: ssoStatus?.microsoft ? '#f0fdf4' : '#fef2f2',
border: `1px solid ${ssoStatus?.microsoft ? '#bbf7d0' : '#fecaca'}`,
marginTop: '0.75rem',
}}
>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
background: ssoStatus?.microsoft
? 'var(--color-success)'
: 'var(--color-error)',
}}
/>
<span
style={{
fontSize: '0.875rem',
fontWeight: 500,
color: ssoStatus?.microsoft ? '#166534' : '#991b1b',
}}
>
{isLoading
? 'Status wird geladen...'
: ssoStatus?.microsoft
@ -160,7 +295,13 @@ export function AdminSsoPage() {
{/* Einrichtungs-Anleitung */}
<div style={cardStyle}>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, marginBottom: '1.25rem' }}>
<h2
style={{
fontSize: '1.125rem',
fontWeight: 600,
marginBottom: '1.25rem',
}}
>
Einrichtungsanleitung
</h2>
@ -175,11 +316,14 @@ export function AdminSsoPage() {
href="https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--color-primary)', textDecoration: 'underline' }}
style={{
color: 'var(--color-primary)',
textDecoration: 'underline',
}}
>
Azure Portal &rarr; App registrations
</a>
{' '}und klicke auf <strong>New registration</strong>.
</a>{' '}
und klicke auf <strong>New registration</strong>.
</p>
<table style={tableStyle}>
<thead>
@ -190,23 +334,37 @@ export function AdminSsoPage() {
</thead>
<tbody>
<tr>
<td style={tdStyle}><strong>Name</strong></td>
<td style={tdStyle}>
<strong>Name</strong>
</td>
<td style={tdStyle}>INSIGHT Platform</td>
</tr>
<tr>
<td style={tdStyle}><strong>Supported account types</strong></td>
<td style={tdStyle}>
Accounts in this organizational directory only<br />
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
<strong>Supported account types</strong>
</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)
</span>
</td>
</tr>
<tr>
<td style={tdStyle}><strong>Redirect URI</strong></td>
<td style={tdStyle}>
Platform: <strong>Web</strong><br />
URI: <code style={inlineCodeStyle}>{redirectUri}</code>
<strong>Redirect URI</strong>
</td>
<td style={tdStyle}>
Platform: <strong>Web</strong>
<br />
URI:{' '}
<code style={inlineCodeStyle}>{defaultRedirectUri}</code>
</td>
</tr>
</tbody>
@ -220,7 +378,9 @@ export function AdminSsoPage() {
<div style={stepContentStyle}>
<h3 style={h3Style}>Client Secret erstellen</h3>
<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>
<table style={tableStyle}>
<thead>
@ -231,30 +391,43 @@ export function AdminSsoPage() {
</thead>
<tbody>
<tr>
<td style={tdStyle}><strong>Description</strong></td>
<td style={tdStyle}>
<strong>Description</strong>
</td>
<td style={tdStyle}>INSIGHT Platform SSO</td>
</tr>
<tr>
<td style={tdStyle}><strong>Expires</strong></td>
<td style={tdStyle}>
24 months (empfohlen)<br />
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
<strong>Expires</strong>
</td>
<td style={tdStyle}>
24 months (empfohlen)
<br />
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text-muted)',
}}
>
Danach muss das Secret erneuert werden
</span>
</td>
</tr>
</tbody>
</table>
<div style={{
marginTop: '0.75rem',
padding: '0.625rem 0.75rem',
background: '#fffbeb',
border: '1px solid #fde68a',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
color: '#92400e',
}}>
<strong>Wichtig:</strong> Den <strong>Value</strong> des Secrets sofort kopieren er wird nur einmalig angezeigt!
<div
style={{
marginTop: '0.75rem',
padding: '0.625rem 0.75rem',
background: '#fffbeb',
border: '1px solid #fde68a',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
color: '#92400e',
}}
>
<strong>Wichtig:</strong> Den <strong>Value</strong> des Secrets
sofort kopieren er wird nur einmalig angezeigt!
</div>
</div>
</div>
@ -265,7 +438,11 @@ export function AdminSsoPage() {
<div style={stepContentStyle}>
<h3 style={h3Style}>API Permissions konfigurieren</h3>
<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 style={pStyle}>Folgende Berechtigungen hinzufügen:</p>
<table style={tableStyle}>
@ -278,133 +455,261 @@ export function AdminSsoPage() {
</thead>
<tbody>
<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}>Sign users in</td>
</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}>View users' basic profile</td>
</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}>View users' email address</td>
</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}>Sign in and read user profile</td>
</tr>
</tbody>
</table>
<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>
</div>
</div>
{/* Schritt 4 */}
{/* Schritt 4 — Konfiguration eingeben und speichern */}
<div style={stepStyle}>
<div style={stepNumberStyle}>4</div>
<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}>
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>
<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 style={stepStyle}>
<div style={stepNumberStyle}>5</div>
<div style={stepContentStyle}>
<h3 style={h3Style}>Umgebungsvariablen auf dem Server setzen</h3>
<p style={pStyle}>
Trage die Werte in die <code style={inlineCodeStyle}>.env</code>-Datei auf dem Server ein:
</p>
<div style={codeBlockStyle}>
{`AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
AZURE_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AZURE_REDIRECT_URI=${redirectUri}`}
</div>
</div>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
marginTop: '1rem',
}}
>
{/* Tenant ID */}
<div>
<label style={labelStyle}>
Directory (Tenant) ID{' '}
<span style={{ color: 'var(--color-error)' }}>*</span>
</label>
<input
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 */}
<div style={stepStyle}>
<div style={stepNumberStyle}>6</div>
<div style={stepContentStyle}>
<h3 style={h3Style}>Core-Service neu starten</h3>
<p style={pStyle}>
Nach dem Setzen der Umgebungsvariablen den Backend-Service neu starten:
</p>
<div style={codeBlockStyle}>
{`docker compose restart core`}
{/* Client ID */}
<div>
<label style={labelStyle}>
Application (Client) ID{' '}
<span style={{ color: 'var(--color-error)' }}>*</span>
</label>
<input
type="text"
value={clientId}
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>
<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>
{/* Funktionsweise */}
<div style={cardStyle}>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, marginBottom: '1rem' }}>
<h2
style={{
fontSize: '1.125rem',
fontWeight: 600,
marginBottom: '1rem',
}}
>
Funktionsweise
</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div style={{
padding: '1rem',
background: 'var(--color-bg)',
borderRadius: 'var(--radius-sm)',
}}>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1rem',
}}
>
<div
style={{
padding: '1rem',
background: 'var(--color-bg)',
borderRadius: 'var(--radius-sm)',
}}
>
<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,
wird automatisch ein Konto angelegt (Rolle: Benutzer).
Existiert bereits ein Konto mit derselben E-Mail-Adresse,
wird das Microsoft-Konto automatisch verknüpft.
wird automatisch ein Konto angelegt (Rolle: Benutzer). Existiert
bereits ein Konto mit derselben E-Mail-Adresse, wird das
Microsoft-Konto automatisch verknüpft.
</p>
</div>
<div style={{
padding: '1rem',
background: 'var(--color-bg)',
borderRadius: 'var(--radius-sm)',
}}>
<div
style={{
padding: '1rem',
background: 'var(--color-bg)',
borderRadius: 'var(--radius-sm)',
}}
>
<h3 style={{ ...h3Style, fontSize: '0.875rem' }}>Sicherheit</h3>
<p style={{ ...pStyle, fontSize: '0.8125rem', marginBottom: 0 }}>
Der SSO-Flow nutzt den OAuth2 Authorization Code Flow.
Das Client Secret verlässt nie den Server.
CSRF-Schutz via State-Parameter.
Die Multi-Faktor-Authentifizierung (MFA) von Microsoft wird unterstützt.
<p
style={{ ...pStyle, fontSize: '0.8125rem', marginBottom: 0 }}
>
Der SSO-Flow nutzt den OAuth2 Authorization Code Flow. Das Client
Secret verlässt nie den Server. CSRF-Schutz via State-Parameter.
Die Multi-Faktor-Authentifizierung (MFA) von Microsoft wird
unterstützt.
</p>
</div>
</div>
@ -412,7 +717,13 @@ AZURE_REDIRECT_URI=${redirectUri}`}
{/* Technische Details */}
<div style={cardStyle}>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, marginBottom: '1rem' }}>
<h2
style={{
fontSize: '1.125rem',
fontWeight: 600,
marginBottom: '1rem',
}}
>
Technische Referenz
</h2>
<table style={tableStyle}>
@ -424,34 +735,68 @@ AZURE_REDIRECT_URI=${redirectUri}`}
</thead>
<tbody>
<tr>
<td style={tdStyle}><code style={inlineCodeStyle}>GET /api/v1/auth/sso/microsoft</code></td>
<td style={tdStyle}>Startet den SSO-Flow (Redirect zu Microsoft)</td>
<td style={tdStyle}>
<code style={inlineCodeStyle}>
GET /api/v1/auth/sso/microsoft
</code>
</td>
<td style={tdStyle}>
Startet den SSO-Flow (Redirect zu Microsoft)
</td>
</tr>
<tr>
<td style={tdStyle}><code style={inlineCodeStyle}>GET /api/v1/auth/sso/microsoft/callback</code></td>
<td style={tdStyle}>Callback von Microsoft (Token-Exchange + User-Provisioning)</td>
<td style={tdStyle}>
<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>
<td style={tdStyle}><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>
<td style={tdStyle}>
<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>
</tbody>
</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}>
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>
<div style={{
...codeBlockStyle,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
}}>
<span>{redirectUri}</span>
<div
style={{
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',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
marginTop: '0.75rem',
}}
>
<span>{defaultRedirectUri}</span>
<button
onClick={() => navigator.clipboard.writeText(redirectUri)}
onClick={() => navigator.clipboard.writeText(defaultRedirectUri)}
style={{
padding: '0.25rem 0.5rem',
background: '#334155',