feat: add Microsoft Entra ID (Azure AD) SSO integration

Backend-driven Authorization Code Flow with @azure/msal-node:
- EntraIdService: MSAL ConfidentialClientApplication, auth URL generation, token exchange
- SsoController: /auth/sso/microsoft (initiate) + /auth/sso/microsoft/callback (callback)
- AuthService.loginViaSso(): User provisioning (find by OID, auto-link by email, or create new)
- CSRF protection via state parameter stored in Redis
- SSO status endpoint for frontend feature detection

Frontend:
- "Mit Microsoft anmelden" button on login page (shown only when SSO is configured)
- SsoCallbackPage: handles redirect from backend, sets token, loads user profile
- AuthContext.loginWithToken(): new method for SSO token handling

Configuration:
- AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_REDIRECT_URI env vars
- docker-compose.yml updated to pass Azure vars to core service

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-09 22:31:34 +01:00
parent 8efaa49930
commit 45cf644f81
14 changed files with 766 additions and 6 deletions

View file

@ -66,8 +66,12 @@ SMTP_FROM=noreply@xinion.de
GRAFANA_ADMIN_USER=admin GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD= # Sicheres Passwort setzen! GRAFANA_ADMIN_PASSWORD= # Sicheres Passwort setzen!
# --- MS SSO (Beta - noch nicht aktiv) --- # --- Microsoft Entra ID (Azure AD) SSO ---
# MS_SSO_CLIENT_ENCRYPTION_KEY= # AES-256 Key fuer Client Secret Verschluesselung # 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) --- # --- KI-Hilfe-Chat (optional) ---
# ANTHROPIC_API_KEY= # Claude API Key # ANTHROPIC_API_KEY= # Claude API Key

View file

@ -241,6 +241,11 @@ services:
BCRYPT_COST: ${BCRYPT_COST:-12} BCRYPT_COST: ${BCRYPT_COST:-12}
# CORS # CORS
CORS_ORIGINS: ${CORS_ORIGINS:-http://172.20.10.59} 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 # Rate Limiting
THROTTLE_TTL: ${THROTTLE_TTL:-60000} THROTTLE_TTL: ${THROTTLE_TTL:-60000}
THROTTLE_LIMIT: ${THROTTLE_LIMIT:-200} THROTTLE_LIMIT: ${THROTTLE_LIMIT:-200}

View file

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@azure/msal-node": "^5.0.6",
"@nestjs/common": "^10.4.0", "@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.0", "@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.4.0", "@nestjs/core": "^10.4.0",
@ -227,6 +228,38 @@
"tslib": "^2.1.0" "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": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",

View file

@ -26,6 +26,7 @@
"prisma:seed": "ts-node prisma/seed.ts" "prisma:seed": "ts-node prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@azure/msal-node": "^5.0.6",
"@nestjs/common": "^10.4.0", "@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.2.0", "@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.4.0", "@nestjs/core": "^10.4.0",

View file

@ -84,6 +84,23 @@ class EnvironmentVariables {
@Type(() => Number) @Type(() => Number)
@IsNumber() @IsNumber()
THROTTLE_LIMIT = 200; 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( export function validateConfig(

View file

@ -7,6 +7,8 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtStrategy } from './strategies/jwt.strategy';
import { TotpService } from './totp.service'; import { TotpService } from './totp.service';
import { EntraIdService } from './sso/entra-id.service';
import { SsoController } from './sso/sso.controller';
@Module({ @Module({
imports: [ imports: [
@ -40,8 +42,8 @@ import { TotpService } from './totp.service';
}, },
}), }),
], ],
controllers: [AuthController], controllers: [AuthController, SsoController],
providers: [AuthService, JwtStrategy, TotpService], providers: [AuthService, JwtStrategy, TotpService, EntraIdService],
exports: [AuthService, JwtModule], exports: [AuthService, JwtModule],
}) })
export class AuthModule {} export class AuthModule {}

View file

@ -339,6 +339,156 @@ export class AuthService {
this.logger.log(`2FA deaktiviert für User ${userId}`); 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<LoginResponse & { refreshToken: string }> {
// 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). * Token-Paar generieren (Access + Refresh).
*/ */

View file

@ -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<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',
);
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<string> {
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<MsUserInfo> {
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<string, unknown>;
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,
};
}
}

View file

@ -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<void> {
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<void> {
const frontendUrl = this.config.get<string>(
'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(),
};
}
}

View file

@ -28,6 +28,7 @@ interface AuthContextType {
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
login: (email: string, password: string, totpCode?: string) => Promise<LoginResult>; login: (email: string, password: string, totpCode?: string) => Promise<LoginResult>;
loginWithToken: (accessToken: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
} }
@ -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<User>('/users/me');
setUser(data);
}, []);
const refreshUser = useCallback(async () => { const refreshUser = useCallback(async () => {
try { try {
const { data } = await api.get<User>('/users/me'); const { data } = await api.get<User>('/users/me');
@ -130,6 +141,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isAuthenticated: !!user, isAuthenticated: !!user,
isLoading, isLoading,
login, login,
loginWithToken,
logout, logout,
refreshUser, refreshUser,
}} }}

View file

@ -104,3 +104,51 @@
font-size: 0.875rem; font-size: 0.875rem;
border: 1px solid #fecaca; 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;
}

View file

@ -1,10 +1,13 @@
import { useState, type FormEvent } from 'react'; import { useState, useEffect, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import api from '../api/client';
import { useAuth } from './AuthContext'; import { useAuth } from './AuthContext';
import styles from './LoginPage.module.css'; import styles from './LoginPage.module.css';
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { login } = useAuth(); const { login } = useAuth();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@ -14,6 +17,25 @@ export function LoginPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); 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) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); 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 ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.card}> <div className={styles.card}>
@ -107,6 +134,33 @@ export function LoginPage() {
{isSubmitting ? 'Anmeldung...' : 'Anmelden'} {isSubmitting ? 'Anmeldung...' : 'Anmelden'}
</button> </button>
</form> </form>
{/* Microsoft SSO Button */}
{ssoStatus?.microsoft && (
<>
<div className={styles.divider}>
<span>oder</span>
</div>
<button
type="button"
className={styles.ssoButton}
onClick={handleMicrosoftLogin}
>
<svg
width="20"
height="20"
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>
Mit Microsoft anmelden
</button>
</>
)}
</div> </div>
</div> </div>
); );

View file

@ -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 (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: 'linear-gradient(135deg, #1a56db 0%, #1e3a5f 100%)',
}}
>
<div
style={{
background: 'white',
borderRadius: '0.75rem',
padding: '2.5rem',
textAlign: 'center',
maxWidth: '400px',
width: '100%',
}}
>
{error ? (
<>
<p style={{ color: '#dc2626', fontSize: '0.9375rem', marginBottom: '0.5rem' }}>
{error}
</p>
<p style={{ color: '#6b7280', fontSize: '0.8125rem' }}>
Sie werden zur Login-Seite weitergeleitet...
</p>
</>
) : (
<>
<div
style={{
width: 32,
height: 32,
border: '3px solid #e5e7eb',
borderTopColor: '#1a56db',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
margin: '0 auto 1rem',
}}
/>
<p style={{ color: '#374151', fontSize: '0.9375rem' }}>
Anmeldung wird abgeschlossen...
</p>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</>
)}
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { LoginPage } from '../auth/LoginPage'; import { LoginPage } from '../auth/LoginPage';
import { SsoCallbackPage } from '../auth/SsoCallbackPage';
import { AppLayout } from './AppLayout'; import { AppLayout } from './AppLayout';
import { DashboardPage } from './DashboardPage'; import { DashboardPage } from './DashboardPage';
import { AdminUsersPage } from '../admin/AdminUsersPage'; import { AdminUsersPage } from '../admin/AdminUsersPage';
@ -30,6 +31,7 @@ export function App() {
<Routes> <Routes>
{/* Öffentliche Routen */} {/* Öffentliche Routen */}
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/auth/sso/callback" element={<SsoCallbackPage />} />
{/* Geschützte Routen */} {/* Geschützte Routen */}
<Route <Route