diff --git a/.env.example b/.env.example index 2e6e0f1..6ce9a1e 100644 --- a/.env.example +++ b/.env.example @@ -66,8 +66,12 @@ SMTP_FROM=noreply@xinion.de GRAFANA_ADMIN_USER=admin GRAFANA_ADMIN_PASSWORD= # Sicheres Passwort setzen! -# --- MS SSO (Beta - noch nicht aktiv) --- -# MS_SSO_CLIENT_ENCRYPTION_KEY= # AES-256 Key fuer Client Secret Verschluesselung +# --- Microsoft Entra ID (Azure AD) SSO --- +# Azure App Registration: https://portal.azure.com → App registrations +AZURE_TENANT_ID= # Directory (Tenant) ID +AZURE_CLIENT_ID= # Application (Client) ID +AZURE_CLIENT_SECRET= # Client Secret Value +AZURE_REDIRECT_URI=http://172.20.10.59/api/v1/auth/sso/microsoft/callback # --- KI-Hilfe-Chat (optional) --- # ANTHROPIC_API_KEY= # Claude API Key diff --git a/docker-compose.yml b/docker-compose.yml index 5b8288c..60ebf4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -241,6 +241,11 @@ services: BCRYPT_COST: ${BCRYPT_COST:-12} # CORS CORS_ORIGINS: ${CORS_ORIGINS:-http://172.20.10.59} + # Microsoft Entra ID (Azure AD) SSO + AZURE_TENANT_ID: ${AZURE_TENANT_ID:-} + AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-} + AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-} + AZURE_REDIRECT_URI: ${AZURE_REDIRECT_URI:-} # Rate Limiting THROTTLE_TTL: ${THROTTLE_TTL:-60000} THROTTLE_LIMIT: ${THROTTLE_LIMIT:-200} diff --git a/packages/core-service/package-lock.json b/packages/core-service/package-lock.json index 7fd0595..caab552 100644 --- a/packages/core-service/package-lock.json +++ b/packages/core-service/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "UNLICENSED", "dependencies": { + "@azure/msal-node": "^5.0.6", "@nestjs/common": "^10.4.0", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.4.0", @@ -227,6 +228,38 @@ "tslib": "^2.1.0" } }, + "node_modules/@azure/msal-common": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.2.0.tgz", + "integrity": "sha512-ge0nGzTLmEE5lg7tSCbTBrYqMGkpFQeQEtqfcKPuGJn/FPFf8Xz51uDfZsm5xpstNZGMYPhHvnYbL8OeNp/aLw==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.0.6.tgz", + "integrity": "sha512-vwGXndrTkf/5Nu0xjobrFXW1AVlrbp2IrTdmJumSERfHXMsBQC+5YqIvLxCqT2+Rn+sBvzRpGaUqHCA8CKAyjg==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.2.0", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@azure/msal-node/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", diff --git a/packages/core-service/package.json b/packages/core-service/package.json index e68d4d3..deafca3 100644 --- a/packages/core-service/package.json +++ b/packages/core-service/package.json @@ -26,6 +26,7 @@ "prisma:seed": "ts-node prisma/seed.ts" }, "dependencies": { + "@azure/msal-node": "^5.0.6", "@nestjs/common": "^10.4.0", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.4.0", diff --git a/packages/core-service/src/config/env.validation.ts b/packages/core-service/src/config/env.validation.ts index ce8fd54..7cc154a 100644 --- a/packages/core-service/src/config/env.validation.ts +++ b/packages/core-service/src/config/env.validation.ts @@ -84,6 +84,23 @@ class EnvironmentVariables { @Type(() => Number) @IsNumber() THROTTLE_LIMIT = 200; + + // Microsoft Entra ID (Azure AD) SSO - optional + @IsOptional() + @IsString() + AZURE_TENANT_ID?: string; + + @IsOptional() + @IsString() + AZURE_CLIENT_ID?: string; + + @IsOptional() + @IsString() + AZURE_CLIENT_SECRET?: string; + + @IsOptional() + @IsString() + AZURE_REDIRECT_URI?: string; } export function validateConfig( diff --git a/packages/core-service/src/core/auth/auth.module.ts b/packages/core-service/src/core/auth/auth.module.ts index 23a19aa..bcbefcd 100644 --- a/packages/core-service/src/core/auth/auth.module.ts +++ b/packages/core-service/src/core/auth/auth.module.ts @@ -7,6 +7,8 @@ import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { TotpService } from './totp.service'; +import { EntraIdService } from './sso/entra-id.service'; +import { SsoController } from './sso/sso.controller'; @Module({ imports: [ @@ -40,8 +42,8 @@ import { TotpService } from './totp.service'; }, }), ], - controllers: [AuthController], - providers: [AuthService, JwtStrategy, TotpService], + controllers: [AuthController, SsoController], + providers: [AuthService, JwtStrategy, TotpService, EntraIdService], exports: [AuthService, JwtModule], }) export class AuthModule {} diff --git a/packages/core-service/src/core/auth/auth.service.ts b/packages/core-service/src/core/auth/auth.service.ts index 6d1323e..ded3b24 100644 --- a/packages/core-service/src/core/auth/auth.service.ts +++ b/packages/core-service/src/core/auth/auth.service.ts @@ -339,6 +339,156 @@ export class AuthService { this.logger.log(`2FA deaktiviert für User ${userId}`); } + /** + * SSO-Login: User via Microsoft Entra ID (Azure AD) anmelden. + * + * Logik: + * 1. User via AuthProvider(MS_SSO, providerId=oid) suchen + * 2. Falls nicht gefunden: User via E-Mail suchen + * → Falls gefunden: MS_SSO AuthProvider verknuepfen (Auto-Link) + * → Falls nicht gefunden: Neuen User + MS_SSO AuthProvider anlegen + * 3. User muss isActive sein + * 4. JWT-Tokens generieren (wie bei normalem Login) + */ + async loginViaSso(msUser: { + oid: string; + email: string; + firstName: string; + lastName: string; + }): Promise { + // 1. Bestehenden MS_SSO AuthProvider suchen (Provider ID = MS Object ID) + const existingAuth = await this.prisma.authProvider.findFirst({ + where: { + provider: 'MS_SSO', + providerId: msUser.oid, + }, + include: { + user: { + include: { + tenantMemberships: { + include: { tenant: true }, + where: { isActive: true }, + take: 1, + }, + }, + }, + }, + }); + + let user: { + id: string; + email: string; + firstName: string; + lastName: string; + role: string; + twoFactorEnabled: boolean; + tenantMemberships?: Array<{ + tenant: { id: string; slug: string }; + }>; + }; + + if (existingAuth) { + // Bekannter SSO-User + user = existingAuth.user; + this.logger.log(`SSO Login: Bekannter User ${user.email}`); + } else { + // 2. User via E-Mail suchen + const existingUser = await this.prisma.user.findUnique({ + where: { email: msUser.email }, + include: { + authProvider: true, + tenantMemberships: { + include: { tenant: true }, + where: { isActive: true }, + take: 1, + }, + }, + }); + + if (existingUser) { + // MS_SSO AuthProvider verknuepfen (Auto-Link) + await this.prisma.authProvider.create({ + data: { + userId: existingUser.id, + provider: 'MS_SSO', + providerId: msUser.oid, + }, + }); + user = existingUser; + this.logger.log( + `SSO Auto-Link: MS_SSO Provider fuer ${user.email} verknuepft`, + ); + } else { + // 3. Neuen User + MS_SSO AuthProvider anlegen + const newUser = await this.prisma.user.create({ + data: { + email: msUser.email, + firstName: msUser.firstName || 'SSO', + lastName: msUser.lastName || 'User', + role: 'USER', + isActive: true, + authProvider: { + create: { + provider: 'MS_SSO', + providerId: msUser.oid, + }, + }, + }, + include: { + tenantMemberships: { + include: { tenant: true }, + where: { isActive: true }, + take: 1, + }, + }, + }); + user = newUser; + this.logger.log(`SSO Neuer User angelegt: ${user.email}`); + } + } + + // User muss aktiv sein + if (!('isActive' in user) || !(user as { isActive: boolean }).isActive) { + throw new UnauthorizedException( + 'Ihr Benutzerkonto ist deaktiviert. Bitte wenden Sie sich an den Administrator.', + ); + } + + // lastLogin aktualisieren + await this.prisma.user.update({ + where: { id: user.id }, + data: { + lastLogin: new Date(), + failedLoginAttempts: 0, + }, + }); + + // Tenant-Info + const primaryMembership = user.tenantMemberships?.[0]; + + // Tokens generieren + const tokens = await this.generateTokenPair({ + sub: user.id, + email: user.email, + role: user.role, + tenantId: primaryMembership?.tenant.id, + tenantSlug: primaryMembership?.tenant.slug, + }); + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + user: { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + twoFactorEnabled: user.twoFactorEnabled, + }, + }; + } + /** * Token-Paar generieren (Access + Refresh). */ 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 new file mode 100644 index 0000000..0c354e4 --- /dev/null +++ b/packages/core-service/src/core/auth/sso/entra-id.service.ts @@ -0,0 +1,166 @@ +import { + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + ConfidentialClientApplication, + type Configuration, + type AuthorizationUrlRequest, + type AuthorizationCodeRequest, + type AuthenticationResult, +} from '@azure/msal-node'; + +/** + * Informationen aus dem Microsoft ID-Token. + */ +export interface MsUserInfo { + /** Microsoft Object ID (eindeutig pro Tenant) */ + oid: string; + /** E-Mail-Adresse */ + email: string; + /** Vorname */ + firstName: string; + /** Nachname */ + lastName: string; +} + +/** + * EntraIdService - Microsoft Entra ID (Azure AD) Integration. + * + * Nutzt MSAL ConfidentialClientApplication fuer den + * Authorization Code Flow (Server-seitig). + */ +@Injectable() +export class EntraIdService { + private readonly logger = new Logger(EntraIdService.name); + private msalClient: ConfidentialClientApplication | null = null; + private readonly redirectUri: string; + private readonly scopes = ['openid', 'profile', 'email', 'User.Read']; + + constructor(private readonly config: ConfigService) { + 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', + ); + + 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'); + } else { + this.logger.warn( + 'Microsoft Entra ID SSO nicht konfiguriert (AZURE_CLIENT_ID, AZURE_TENANT_ID oder AZURE_CLIENT_SECRET fehlt)', + ); + } + } + + /** + * Ist Entra ID SSO konfiguriert? + */ + isConfigured(): boolean { + return !!this.msalClient; + } + + /** + * Authorization-URL fuer den OAuth2 Flow generieren. + * @param state CSRF-Token (wird in Redis gespeichert) + */ + async getAuthUrl(state: string): Promise { + if (!this.msalClient) { + throw new ServiceUnavailableException( + 'Microsoft SSO ist nicht konfiguriert', + ); + } + + const authUrlRequest: AuthorizationUrlRequest = { + scopes: this.scopes, + redirectUri: this.redirectUri, + state, + prompt: 'select_account', + }; + + const authUrl = await this.msalClient.getAuthCodeUrl(authUrlRequest); + this.logger.debug('Authorization URL generiert'); + return authUrl; + } + + /** + * Authorization Code gegen Tokens tauschen und User-Info extrahieren. + * @param code Authorization Code von Microsoft + */ + async handleCallback(code: string): Promise { + if (!this.msalClient) { + throw new ServiceUnavailableException( + 'Microsoft SSO ist nicht konfiguriert', + ); + } + + const tokenRequest: AuthorizationCodeRequest = { + code, + scopes: this.scopes, + redirectUri: this.redirectUri, + }; + + const response: AuthenticationResult = + await this.msalClient.acquireTokenByCode(tokenRequest); + + this.logger.debug('Token erfolgreich erhalten'); + + // User-Informationen aus ID-Token Claims extrahieren + const claims = response.idTokenClaims as Record; + + const oid = (claims.oid as string) || (claims.sub as string); + if (!oid) { + throw new Error('Keine Object ID (oid) im ID-Token gefunden'); + } + + // E-Mail: preferred_username, email, oder upn + const email = + (claims.preferred_username as string) || + (claims.email as string) || + (claims.upn as string) || + ''; + + if (!email) { + throw new Error('Keine E-Mail-Adresse im ID-Token gefunden'); + } + + // Namen: given_name + family_name, oder name splitten + let firstName = (claims.given_name as string) || ''; + let lastName = (claims.family_name as string) || ''; + + if (!firstName && !lastName && claims.name) { + const parts = (claims.name as string).split(' '); + firstName = parts[0] || ''; + lastName = parts.slice(1).join(' ') || ''; + } + + this.logger.log(`MS SSO User: ${email} (OID: ${oid})`); + + return { + oid, + email: email.toLowerCase(), + firstName, + lastName, + }; + } +} diff --git a/packages/core-service/src/core/auth/sso/sso.controller.ts b/packages/core-service/src/core/auth/sso/sso.controller.ts new file mode 100644 index 0000000..d6b1825 --- /dev/null +++ b/packages/core-service/src/core/auth/sso/sso.controller.ts @@ -0,0 +1,165 @@ +import { + Controller, + Get, + Query, + Res, + Logger, + ServiceUnavailableException, + UnauthorizedException, + InternalServerErrorException, +} 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 { RedisService } from '../../../redis/redis.service'; +import { EntraIdService } from './entra-id.service'; +import { AuthService } from '../auth.service'; + +/** + * SsoController - Microsoft Entra ID SSO Endpoints. + * + * Flow: + * 1. GET /auth/sso/microsoft → Redirect zu Microsoft Login + * 2. GET /auth/sso/microsoft/callback → Callback von Microsoft, User anlegen/verknuepfen, JWT generieren + */ +@ApiTags('SSO') +@Controller('auth/sso') +export class SsoController { + private readonly logger = new Logger(SsoController.name); + + constructor( + private readonly entraIdService: EntraIdService, + private readonly authService: AuthService, + private readonly redis: RedisService, + private readonly config: ConfigService, + ) {} + + /** + * GET /api/v1/auth/sso/microsoft + * Initiiert den OAuth2 Authorization Code Flow. + * Redirectet den Browser zur Microsoft-Login-Seite. + */ + @Get('microsoft') + @Public() + @ApiOperation({ summary: 'Microsoft SSO Login starten' }) + async initiate(@Res() res: Response): Promise { + if (!this.entraIdService.isConfigured()) { + throw new ServiceUnavailableException( + 'Microsoft SSO ist nicht konfiguriert', + ); + } + + // CSRF-State generieren und in Redis speichern (5 Minuten TTL) + const state = uuidv4(); + await this.redis.set(`sso_state:${state}`, '1', 300); + + // Authorization URL von MSAL holen + const authUrl = await this.entraIdService.getAuthUrl(state); + + this.logger.log('SSO Flow gestartet, Redirect zu Microsoft'); + res.redirect(authUrl); + } + + /** + * GET /api/v1/auth/sso/microsoft/callback + * Callback von Microsoft nach erfolgreicher Authentifizierung. + * Erstellt/verknuepft User, generiert JWT, redirectet zum Frontend. + */ + @Get('microsoft/callback') + @Public() + @ApiOperation({ summary: 'Microsoft SSO Callback' }) + async callback( + @Query('code') code: string, + @Query('state') state: string, + @Query('error') error: string, + @Query('error_description') errorDescription: string, + @Res() res: Response, + ): Promise { + const frontendUrl = this.config.get( + 'FRONTEND_URL', + 'http://172.20.10.59', + ); + + // Fehler von Microsoft + if (error) { + this.logger.warn(`SSO Fehler von Microsoft: ${error} - ${errorDescription}`); + res.redirect( + `${frontendUrl}/login?sso_error=${encodeURIComponent(errorDescription || error)}`, + ); + return; + } + + // Code und State validieren + if (!code || !state) { + this.logger.warn('SSO Callback ohne Code oder State'); + res.redirect(`${frontendUrl}/login?sso_error=Ungültige SSO-Antwort`); + return; + } + + // CSRF-State aus Redis validieren + const storedState = await this.redis.get(`sso_state:${state}`); + if (!storedState) { + this.logger.warn('SSO State ungültig oder abgelaufen'); + res.redirect( + `${frontendUrl}/login?sso_error=SSO-Sitzung abgelaufen. Bitte erneut versuchen.`, + ); + return; + } + + // State verbrauchen (einmalig verwendbar) + await this.redis.del(`sso_state:${state}`); + + try { + // Code gegen Tokens tauschen und User-Info extrahieren + const msUser = await this.entraIdService.handleCallback(code); + + // User finden oder anlegen + JWT generieren + const loginResult = await this.authService.loginViaSso(msUser); + + // Refresh-Token als HttpOnly Cookie setzen (wie beim normalen Login) + const isProduction = process.env.NODE_ENV === 'production'; + res.cookie('refresh_token', loginResult.refreshToken, { + httpOnly: true, + secure: isProduction, + sameSite: isProduction ? 'strict' : 'lax', + path: '/api/v1/auth', + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 Tage + }); + + // Redirect zum Frontend mit Access-Token + this.logger.log(`SSO Login erfolgreich: ${msUser.email}`); + res.redirect( + `${frontendUrl}/auth/sso/callback?token=${loginResult.accessToken}`, + ); + } catch (err) { + this.logger.error(`SSO Callback Fehler: ${(err as Error).message}`); + + if (err instanceof UnauthorizedException) { + res.redirect( + `${frontendUrl}/login?sso_error=${encodeURIComponent((err as Error).message)}`, + ); + return; + } + + res.redirect( + `${frontendUrl}/login?sso_error=SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.`, + ); + } + } + + /** + * GET /api/v1/auth/sso/status + * Pruefen ob Microsoft SSO konfiguriert ist. + * Wird vom Frontend genutzt um den SSO-Button anzuzeigen. + */ + @Get('status') + @Public() + @ApiOperation({ summary: 'SSO Status abfragen' }) + async getStatus() { + return { + microsoft: this.entraIdService.isConfigured(), + }; + } +} diff --git a/packages/frontend/src/auth/AuthContext.tsx b/packages/frontend/src/auth/AuthContext.tsx index 8f88831..144cd32 100644 --- a/packages/frontend/src/auth/AuthContext.tsx +++ b/packages/frontend/src/auth/AuthContext.tsx @@ -28,6 +28,7 @@ interface AuthContextType { isAuthenticated: boolean; isLoading: boolean; login: (email: string, password: string, totpCode?: string) => Promise; + loginWithToken: (accessToken: string) => Promise; logout: () => Promise; refreshUser: () => Promise; } @@ -103,6 +104,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { [], ); + /** + * SSO-Login: Access-Token direkt setzen und User-Profil laden. + * Wird von SsoCallbackPage aufgerufen. + */ + const loginWithToken = useCallback(async (token: string) => { + setAccessToken(token); + const { data } = await api.get('/users/me'); + setUser(data); + }, []); + const refreshUser = useCallback(async () => { try { const { data } = await api.get('/users/me'); @@ -130,6 +141,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: !!user, isLoading, login, + loginWithToken, logout, refreshUser, }} diff --git a/packages/frontend/src/auth/LoginPage.module.css b/packages/frontend/src/auth/LoginPage.module.css index 37206ab..ec4f79f 100644 --- a/packages/frontend/src/auth/LoginPage.module.css +++ b/packages/frontend/src/auth/LoginPage.module.css @@ -104,3 +104,51 @@ font-size: 0.875rem; border: 1px solid #fecaca; } + +.divider { + display: flex; + align-items: center; + margin: 1.5rem 0 1rem; + gap: 0.75rem; +} + +.divider::before, +.divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--color-border); +} + +.divider span { + font-size: 0.8125rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.ssoButton { + display: flex; + align-items: center; + justify-content: center; + gap: 0.625rem; + width: 100%; + padding: 0.75rem; + background: white; + color: #374151; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.ssoButton:hover { + background: #f9fafb; + border-color: #9ca3af; +} + +.ssoButton svg { + flex-shrink: 0; +} diff --git a/packages/frontend/src/auth/LoginPage.tsx b/packages/frontend/src/auth/LoginPage.tsx index 1574ddb..b0f5169 100644 --- a/packages/frontend/src/auth/LoginPage.tsx +++ b/packages/frontend/src/auth/LoginPage.tsx @@ -1,10 +1,13 @@ -import { useState, type FormEvent } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useState, useEffect, type FormEvent } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import api from '../api/client'; import { useAuth } from './AuthContext'; import styles from './LoginPage.module.css'; export function LoginPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { login } = useAuth(); const [email, setEmail] = useState(''); @@ -14,6 +17,25 @@ export function LoginPage() { const [error, setError] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + // SSO-Fehler aus URL-Parameter lesen (Redirect von Backend) + useEffect(() => { + const ssoError = searchParams.get('sso_error'); + if (ssoError) { + setError(ssoError); + } + }, [searchParams]); + + // SSO-Status abfragen: Ist Microsoft SSO konfiguriert? + const { data: ssoStatus } = useQuery<{ microsoft: boolean }>({ + queryKey: ['sso', 'status'], + queryFn: async () => { + const res = await api.get<{ microsoft: boolean }>('/auth/sso/status'); + return res.data; + }, + staleTime: 5 * 60 * 1000, // 5 Minuten cachen + retry: false, + }); + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); setError(''); @@ -42,6 +64,11 @@ export function LoginPage() { } }; + const handleMicrosoftLogin = () => { + // Direkt zum Backend SSO-Endpoint redirecten + window.location.href = '/api/v1/auth/sso/microsoft'; + }; + return (
@@ -107,6 +134,33 @@ export function LoginPage() { {isSubmitting ? 'Anmeldung...' : 'Anmelden'} + + {/* Microsoft SSO Button */} + {ssoStatus?.microsoft && ( + <> +
+ oder +
+ + + )}
); diff --git a/packages/frontend/src/auth/SsoCallbackPage.tsx b/packages/frontend/src/auth/SsoCallbackPage.tsx new file mode 100644 index 0000000..47e5d3b --- /dev/null +++ b/packages/frontend/src/auth/SsoCallbackPage.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { setAccessToken } from '../api/client'; +import { useAuth } from './AuthContext'; + +/** + * SsoCallbackPage - Verarbeitet den SSO-Callback vom Backend. + * + * Das Backend redirectet hierher mit dem Access-Token als Query-Parameter: + * /auth/sso/callback?token=eyJhbGci... + * + * Diese Seite: + * 1. Liest den Token aus der URL + * 2. Setzt ihn im AuthContext (loginWithToken) + * 3. Laedt das User-Profil + * 4. Navigiert zum Dashboard + */ +export function SsoCallbackPage() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { loginWithToken } = useAuth(); + const [error, setError] = useState(''); + + useEffect(() => { + const handleCallback = async () => { + const token = searchParams.get('token'); + + if (!token) { + setError('Kein Token in der SSO-Antwort gefunden.'); + setTimeout(() => navigate('/login'), 2000); + return; + } + + try { + // Token setzen und User-Profil laden + await loginWithToken(token); + + // Zum Dashboard navigieren + navigate('/', { replace: true }); + } catch { + setError('SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.'); + setAccessToken(null); + setTimeout(() => navigate('/login'), 2000); + } + }; + + handleCallback(); + }, [searchParams, navigate, loginWithToken]); + + return ( +
+
+ {error ? ( + <> +

+ {error} +

+

+ Sie werden zur Login-Seite weitergeleitet... +

+ + ) : ( + <> +
+

+ Anmeldung wird abgeschlossen... +

+ + + )} +
+
+ ); +} diff --git a/packages/frontend/src/shell/App.tsx b/packages/frontend/src/shell/App.tsx index 4deb8eb..31959c5 100644 --- a/packages/frontend/src/shell/App.tsx +++ b/packages/frontend/src/shell/App.tsx @@ -1,6 +1,7 @@ import { Routes, Route, Navigate } from 'react-router-dom'; import { useAuth } from '../auth/AuthContext'; import { LoginPage } from '../auth/LoginPage'; +import { SsoCallbackPage } from '../auth/SsoCallbackPage'; import { AppLayout } from './AppLayout'; import { DashboardPage } from './DashboardPage'; import { AdminUsersPage } from '../admin/AdminUsersPage'; @@ -30,6 +31,7 @@ export function App() { {/* Öffentliche Routen */} } /> + } /> {/* Geschützte Routen */}