fix(ms365): dynamische Redirect-URI aus Request-Host + Azure-Kompatibilität

Problem: Redirect-URI wurde falsch aus SSO-URI abgeleitet, und unterstützte
nur eine feste URL statt sowohl IP als auch DNS-Name.

Lösung:
- initM365Integration: Host aus x-forwarded-host/host Header lesen,
  korrekte Redirect-URI bauen (proto://host/api/v1/auth/integrations/...)
- Redis-State speichert jetzt {userId, redirectUri} als JSON
- handleIntegrationCallback: gespeicherte redirectUri aus State verwenden
- getIntegrationAuthUrl/handleIntegrationCallback: optionaler redirectUri-Parameter
- Fallback-Derivation: base URL aus SSO-URI + fester Integrations-Pfad

Beide URIs müssen in Azure registriert sein:
- http://172.20.10.59/api/v1/auth/integrations/microsoft-365/callback
- http://insight.xinion.lan/api/v1/auth/integrations/microsoft-365/callback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-12 23:01:09 +01:00
parent 1ecd7dad82
commit 254d00c106
2 changed files with 51 additions and 16 deletions

View file

@ -141,10 +141,19 @@ export class EntraIdService implements OnModuleInit {
this.msalClient = new ConfidentialClientApplication(msalConfig); this.msalClient = new ConfidentialClientApplication(msalConfig);
this.redirectUri = ssoConfig.redirectUri; this.redirectUri = ssoConfig.redirectUri;
// Integration-Redirect-URI aus Env (Fallback: SSO-URI + '/integration') // Integration-Redirect-URI: Env-Variable hat Vorrang.
this.integrationRedirectUri = // Fallback: Base-URL der SSO-URI + fester Pfad
this.config.get<string>('AZURE_INTEGRATION_REDIRECT_URI') || const explicitIntegrationUri = this.config.get<string>('AZURE_INTEGRATION_REDIRECT_URI');
ssoConfig.redirectUri.replace('/callback', '/integration/callback'); if (explicitIntegrationUri) {
this.integrationRedirectUri = explicitIntegrationUri;
} else {
try {
const ssoUrl = new URL(ssoConfig.redirectUri);
this.integrationRedirectUri = `${ssoUrl.protocol}//${ssoUrl.host}/api/v1/auth/integrations/microsoft-365/callback`;
} catch {
this.integrationRedirectUri = 'http://localhost:3000/api/v1/auth/integrations/microsoft-365/callback';
}
}
} }
/** /**
@ -324,8 +333,10 @@ export class EntraIdService implements OnModuleInit {
/** /**
* Authorization-URL fuer MS365 Integration generieren. * Authorization-URL fuer MS365 Integration generieren.
* Verwendet erweiterte Scopes (Mail, Calendar, Tasks). * Verwendet erweiterte Scopes (Mail, Calendar, Tasks).
* @param state CSRF-Token
* @param redirectUri Optionaler Override (fuer dynamischen Host-basierten URI)
*/ */
async getIntegrationAuthUrl(state: string): Promise<string> { async getIntegrationAuthUrl(state: string, redirectUri?: string): Promise<string> {
if (!this.msalClient) { if (!this.msalClient) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
'Microsoft SSO ist nicht konfiguriert', 'Microsoft SSO ist nicht konfiguriert',
@ -334,7 +345,7 @@ export class EntraIdService implements OnModuleInit {
const authUrlRequest: AuthorizationUrlRequest = { const authUrlRequest: AuthorizationUrlRequest = {
scopes: this.integrationScopes, scopes: this.integrationScopes,
redirectUri: this.integrationRedirectUri, redirectUri: redirectUri || this.integrationRedirectUri,
state, state,
prompt: 'consent', // Immer Zustimmung anfordern fuer neue Scopes prompt: 'consent', // Immer Zustimmung anfordern fuer neue Scopes
}; };
@ -345,8 +356,10 @@ export class EntraIdService implements OnModuleInit {
/** /**
* Authorization Code gegen M365-Tokens tauschen. * Authorization Code gegen M365-Tokens tauschen.
* Gibt Access-Token, Refresh-Token und Ablaufzeit zurueck. * Gibt Access-Token, Refresh-Token und Ablaufzeit zurueck.
* @param code Authorization Code von Microsoft
* @param redirectUri Muss exakt mit dem URI aus dem Auth-Request uebereinstimmen
*/ */
async handleIntegrationCallback(code: string): Promise<M365TokenResult> { async handleIntegrationCallback(code: string, redirectUri?: string): Promise<M365TokenResult> {
if (!this.msalClient) { if (!this.msalClient) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
'Microsoft SSO ist nicht konfiguriert', 'Microsoft SSO ist nicht konfiguriert',
@ -356,7 +369,7 @@ export class EntraIdService implements OnModuleInit {
const tokenRequest: AuthorizationCodeRequest = { const tokenRequest: AuthorizationCodeRequest = {
code, code,
scopes: this.integrationScopes, scopes: this.integrationScopes,
redirectUri: this.integrationRedirectUri, redirectUri: redirectUri || this.integrationRedirectUri,
}; };
const response: AuthenticationResult = const response: AuthenticationResult =

View file

@ -3,10 +3,12 @@ import {
Get, Get,
Delete, Delete,
Query, Query,
Req,
Res, Res,
Logger, Logger,
Param, Param,
} from '@nestjs/common'; } from '@nestjs/common';
import { Request } from 'express';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Response } from 'express'; import { Response } from 'express';
@ -60,18 +62,27 @@ export class IntegrationsController {
@ApiOperation({ summary: 'Microsoft 365 Integration starten' }) @ApiOperation({ summary: 'Microsoft 365 Integration starten' })
async initM365Integration( async initM365Integration(
@CurrentUser() user: JwtUser, @CurrentUser() user: JwtUser,
@Req() req: Request,
): Promise<{ success: boolean; data: { url: string } }> { ): Promise<{ success: boolean; data: { url: string } }> {
// State = UUID, in Redis mit userId hinterlegen (5 Min TTL) // Redirect-URI dynamisch aus dem Anfrage-Host ableiten
// Unterstuetzt sowohl IP als auch DNS-Name (z.B. insight.xinion.lan)
const host = (req.get('x-forwarded-host') || req.get('host') || '').split(',')[0].trim();
const proto = req.get('x-forwarded-proto') || req.protocol || 'http';
const redirectUri = host
? `${proto}://${host}/api/v1/auth/integrations/microsoft-365/callback`
: undefined;
// State = UUID, in Redis hinterlegen: {userId, redirectUri} — 5 Min TTL
const state = uuidv4(); const state = uuidv4();
await this.redis.set( await this.redis.set(
`integration_state:${state}`, `integration_state:${state}`,
user.sub, JSON.stringify({ userId: user.sub, redirectUri: redirectUri ?? '' }),
300, 300,
); );
const authUrl = await this.entraIdService.getIntegrationAuthUrl(state); const authUrl = await this.entraIdService.getIntegrationAuthUrl(state, redirectUri);
this.logger.log( this.logger.log(
`M365-Integration OAuth-Flow gestartet fuer User ${user.sub}`, `M365-Integration OAuth-Flow gestartet fuer User ${user.sub} (redirectUri: ${redirectUri ?? 'default'})`,
); );
return { success: true, data: { url: authUrl } }; return { success: true, data: { url: authUrl } };
} }
@ -108,17 +119,28 @@ export class IntegrationsController {
return; return;
} }
// State aus Redis laden (enthaelt userId) // State aus Redis laden (enthaelt {userId, redirectUri})
const userId = await this.redis.get(`integration_state:${state}`); const rawState = await this.redis.get(`integration_state:${state}`);
if (!userId) { if (!rawState) {
this.logger.warn('M365-Integration State ungueltig oder abgelaufen'); this.logger.warn('M365-Integration State ungueltig oder abgelaufen');
res.redirect(`${errorBase}&message=Sitzung+abgelaufen`); res.redirect(`${errorBase}&message=Sitzung+abgelaufen`);
return; return;
} }
await this.redis.del(`integration_state:${state}`); await this.redis.del(`integration_state:${state}`);
let userId: string;
let savedRedirectUri: string | undefined;
try { try {
const tokenResult = await this.entraIdService.handleIntegrationCallback(code); const parsed = JSON.parse(rawState) as { userId: string; redirectUri?: string };
userId = parsed.userId;
savedRedirectUri = parsed.redirectUri || undefined;
} catch {
// Fallback: alter Format (nur userId als String)
userId = rawState;
}
try {
const tokenResult = await this.entraIdService.handleIntegrationCallback(code, savedRedirectUri);
await this.integrationsService.saveM365Integration(userId, tokenResult); await this.integrationsService.saveM365Integration(userId, tokenResult);
this.logger.log(`M365-Integration erfolgreich fuer User ${userId}`); this.logger.log(`M365-Integration erfolgreich fuer User ${userId}`);
res.redirect(successUrl); res.redirect(successUrl);