diff --git a/packages/core-service/src/core/auth/sso/entra-id.service.ts b/packages/core-service/src/core/auth/sso/entra-id.service.ts index 0c354e4..26606f5 100644 --- a/packages/core-service/src/core/auth/sso/entra-id.service.ts +++ b/packages/core-service/src/core/auth/sso/entra-id.service.ts @@ -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 { + // 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('AZURE_CLIENT_ID'); const tenantId = this.config.get('AZURE_TENANT_ID'); const clientSecret = this.config.get('AZURE_CLIENT_SECRET'); - this.redirectUri = this.config.get( - 'AZURE_REDIRECT_URI', - 'http://localhost/api/v1/auth/sso/microsoft/callback', - ); + const redirectUri = this.config.get('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 { + 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 { + // 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 & { 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('AZURE_CLIENT_ID'); + const tenantId = this.config.get('AZURE_TENANT_ID'); + const clientSecret = this.config.get('AZURE_CLIENT_SECRET'); + const redirectUri = this.config.get('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? */ diff --git a/packages/core-service/src/core/auth/sso/sso.controller.ts b/packages/core-service/src/core/auth/sso/sso.controller.ts index d6b1825..a204e29 100644 --- a/packages/core-service/src/core/auth/sso/sso.controller.ts +++ b/packages/core-service/src/core/auth/sso/sso.controller.ts @@ -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. diff --git a/packages/frontend/src/admin/AdminSsoPage.tsx b/packages/frontend/src/admin/AdminSsoPage.tsx index 5b44776..5f19cce 100644 --- a/packages/frontend/src/admin/AdminSsoPage.tsx +++ b/packages/frontend/src/admin/AdminSsoPage.tsx @@ -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({ + const queryClient = useQueryClient(); + + const { data: ssoStatus, isLoading: statusLoading } = useQuery({ queryKey: ['sso', 'status'], queryFn: async () => { const res = await api.get('/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({ + queryKey: ['sso', 'config'], + queryFn: async () => { + const res = await api.get('/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 (
-
-

SSO-Konfiguration

+
+

+ SSO-Konfiguration +

{/* Status-Anzeige */}
-
- +
+ -

Microsoft Entra ID (Azure AD)

+

+ Microsoft Entra ID (Azure AD) +

-
- - +
+ + {isLoading ? 'Status wird geladen...' : ssoStatus?.microsoft @@ -160,7 +295,13 @@ export function AdminSsoPage() { {/* Einrichtungs-Anleitung */}
-

+

Einrichtungsanleitung

@@ -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 → App registrations - - {' '}und klicke auf New registration. + {' '} + und klicke auf New registration.

@@ -190,23 +334,37 @@ export function AdminSsoPage() { - + - + - + @@ -220,7 +378,9 @@ export function AdminSsoPage() {

Client Secret erstellen

- In der App Registration → Certificates & secretsNew client secret. + In der App Registration →{' '} + Certificates & secrets →{' '} + New client secret.

Name + Name + INSIGHT Platform
Supported account types - Accounts in this organizational directory only
- + Supported account types +
+ Accounts in this organizational directory only +
+ (Single tenant — nur euer Azure AD)
Redirect URI - Platform: Web
- URI: {redirectUri} + Redirect URI +
+ Platform: Web +
+ URI:{' '} + {defaultRedirectUri}
@@ -231,30 +391,43 @@ export function AdminSsoPage() { - + - +
Description + Description + INSIGHT Platform SSO
Expires - 24 months (empfohlen)
- + Expires +
+ 24 months (empfohlen) +
+ Danach muss das Secret erneuert werden
-
- Wichtig: Den Value des Secrets sofort kopieren — er wird nur einmalig angezeigt! +
+ Wichtig: Den Value des Secrets + sofort kopieren — er wird nur einmalig angezeigt!
@@ -265,7 +438,11 @@ export function AdminSsoPage() {

API Permissions konfigurieren

- In der App Registration → API permissionsAdd a permissionMicrosoft GraphDelegated permissions. + In der App Registration →{' '} + API permissions →{' '} + Add a permission →{' '} + Microsoft Graph →{' '} + Delegated permissions.

Folgende Berechtigungen hinzufügen:

@@ -278,133 +455,261 @@ export function AdminSsoPage() { - + - + - + - +
openid + openid + Delegated Sign users in
profile + profile + Delegated View users' basic profile
email + email + Delegated View users' email address
User.Read + User.Read + Delegated Sign in and read user profile

- Dann Grant admin consent klicken, um die Berechtigungen für alle Benutzer freizugeben. + Dann Grant admin consent klicken, um die + Berechtigungen für alle Benutzer freizugeben.

- {/* Schritt 4 */} + {/* Schritt 4 — Konfiguration eingeben und speichern */}
4
-

Werte aus Azure Portal kopieren

+

+ Werte aus Azure Portal hier eintragen und speichern +

- In der App Registration → Overview findest du die benötigten Werte: + Kopiere die Werte aus der App Registration →{' '} + Overview und trage sie in die Felder ein. Beim + Speichern wird die SSO-Verbindung automatisch aktiviert.

- - - - - - - - - - - - - - - - - - - - - -
Azure Portal FeldUmgebungsvariable
Directory (tenant) IDAZURE_TENANT_ID
Application (client) IDAZURE_CLIENT_ID
Client secret value (aus Schritt 2)AZURE_CLIENT_SECRET
-
-
- {/* Schritt 5 */} -
-
5
-
-

Umgebungsvariablen auf dem Server setzen

-

- Trage die Werte in die .env-Datei auf dem Server ein: -

-
-{`AZURE_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -AZURE_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -AZURE_REDIRECT_URI=${redirectUri}`} -
-
-
+
+ {/* Tenant ID */} +
+ + setTenantId(e.target.value)} + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + style={inputStyle} + /> +
+ Azure Portal → App Registration → Overview → + Directory (tenant) ID +
+
- {/* Schritt 6 */} -
-
6
-
-

Core-Service neu starten

-

- Nach dem Setzen der Umgebungsvariablen den Backend-Service neu starten: -

-
-{`docker compose restart core`} + {/* Client ID */} +
+ + setClientId(e.target.value)} + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + style={inputStyle} + /> +
+ Azure Portal → App Registration → Overview → + Application (client) ID +
+
+ + {/* Client Secret */} +
+ + setClientSecret(e.target.value)} + placeholder={ + hasExistingConfig + ? `Gespeichert (${ssoConfig?.config?.clientSecretMasked}) — leer lassen um beizubehalten` + : 'Client Secret Value eingeben' + } + style={inputStyle} + /> +
+ Azure Portal → App Registration → Certificates & + secrets → Client secret Value + {hasExistingConfig && ( + + {' '} + — Leer lassen, um das bestehende Secret zu behalten + + )} +
+
+ + {/* Redirect URI */} +
+ + setRedirectUri(e.target.value)} + placeholder={defaultRedirectUri} + style={inputStyle} + /> +
+ Muss exakt mit der Redirect URI in der Azure App Registration + übereinstimmen +
+
+ + {/* Speichern-Button + Status */} +
+ + + {saveSuccess && ( + + SSO-Konfiguration gespeichert und aktiviert! + + )} + + {saveMutation.isError && ( + + Fehler:{' '} + {(saveMutation.error as Error)?.message || + 'Konfiguration konnte nicht gespeichert werden'} + + )} +
-

- Nach dem Neustart sollte der Status oben auf{' '} - aktiv wechseln und - der „Mit Microsoft anmelden"-Button auf der Login-Seite erscheinen. -

{/* Funktionsweise */}
-

+

Funktionsweise

-
-
+
+

Erstanmeldung

-

+

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.

-
+

Sicherheit

-

- 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. +

+ 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.

@@ -412,7 +717,13 @@ AZURE_REDIRECT_URI=${redirectUri}`} {/* Technische Details */}
-

+

Technische Referenz

@@ -424,34 +735,68 @@ AZURE_REDIRECT_URI=${redirectUri}`} - - + + - - + + - - + +
GET /api/v1/auth/sso/microsoftStartet den SSO-Flow (Redirect zu Microsoft) + + GET /api/v1/auth/sso/microsoft + + + Startet den SSO-Flow (Redirect zu Microsoft) +
GET /api/v1/auth/sso/microsoft/callbackCallback von Microsoft (Token-Exchange + User-Provisioning) + + GET /api/v1/auth/sso/microsoft/callback + + + Callback von Microsoft (Token-Exchange + User-Provisioning) +
GET /api/v1/auth/sso/statusPrüft ob SSO konfiguriert ist (für Login-Seite) + + GET /api/v1/auth/sso/status + + + Prüft ob SSO konfiguriert ist (für Login-Seite) +
-

Redirect URI

+

+ Redirect URI +

- Diese URI muss exakt so in der Azure App Registration eingetragen sein: + Diese URI muss exakt so in der Azure App Registration eingetragen + sein:

-
- {redirectUri} +
+ {defaultRedirectUri}