feat(core): Microsoft 365 OAuth-Integration — UserIntegration + IntegrationsModule

- Neues Prisma-Model UserIntegration (AES-256-GCM verschluesselte Tokens)
- Migration: 20260312_user_integrations
- EntraIdService: getIntegrationAuthUrl(), handleIntegrationCallback(), refreshIntegrationToken()
  — erweiterte Scopes: Mail.Read, Calendars.Read, Tasks.ReadWrite, offline_access
- IntegrationsModule mit Controller + Service:
  GET  /auth/integrations/microsoft-365          → OAuth-Flow starten
  GET  /auth/integrations/microsoft-365/callback → Token speichern
  GET  /users/me/integrations                    → Verbindungen auflisten
  DELETE /users/me/integrations/microsoft-365    → Verbindung trennen
  GET  /users/me/integrations/microsoft-365/token → Token fuer CRM-Service
- Env: AZURE_INTEGRATION_REDIRECT_URI, INTEGRATION_ENCRYPTION_KEY

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-12 22:36:03 +01:00
parent 237e0772e6
commit 28f6ba84b0
8 changed files with 620 additions and 0 deletions

View file

@ -54,10 +54,36 @@ model User {
tenantMemberships TenantMembership[]
auditLogs AuditLog[]
expertProfile ExpertProfile?
integrations UserIntegration[]
@@map("users")
}
// --------------------------------------------------------
// UserIntegration - Drittanbieter-OAuth-Tokens pro User
// --------------------------------------------------------
// Speichert Access- und Refresh-Tokens fuer externe Services
// (Microsoft 365 Graph API). Tokens werden AES-256-GCM verschluesselt.
model UserIntegration {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
provider String @db.VarChar(50) // MICROSOFT_365
accessToken String @map("access_token") @db.Text // AES-256-GCM verschluesselt
refreshToken String @map("refresh_token") @db.Text // AES-256-GCM verschluesselt
expiresAt DateTime @map("expires_at")
scopes String[] @default([])
msTenantId String? @map("ms_tenant_id") @db.VarChar(100)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, provider])
@@index([userId])
@@map("user_integrations")
}
// --------------------------------------------------------
// AuthProvider - Authentifizierungs-Provider pro User
// --------------------------------------------------------

View file

@ -0,0 +1,22 @@
-- Migration: 20260312_user_integrations
-- Drittanbieter-OAuth-Token-Speicher fuer User-Integrationen (z.B. Microsoft 365)
CREATE TABLE "user_integrations" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"user_id" UUID NOT NULL,
"provider" VARCHAR(50) NOT NULL,
"access_token" TEXT NOT NULL,
"refresh_token" TEXT NOT NULL,
"expires_at" TIMESTAMPTZ NOT NULL,
"scopes" TEXT[] NOT NULL DEFAULT '{}',
"ms_tenant_id" VARCHAR(100),
"created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT "user_integrations_pkey" PRIMARY KEY ("id"),
CONSTRAINT "user_integrations_user_id_provider_key" UNIQUE ("user_id", "provider"),
CONSTRAINT "user_integrations_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
);
CREATE INDEX "user_integrations_user_id_idx" ON "user_integrations"("user_id");

View file

@ -11,6 +11,7 @@ import { UsersModule } from './core/users/users.module';
import { TenantsModule } from './core/tenants/tenants.module';
import { ExpertProfileModule } from './core/expert-profile/expert-profile.module';
import { SettingsModule } from './core/settings/settings.module';
import { IntegrationsModule } from './core/integrations/integrations.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { validateConfig } from './config/env.validation';
@ -44,6 +45,7 @@ import { validateConfig } from './config/env.validation';
TenantsModule,
ExpertProfileModule,
SettingsModule,
IntegrationsModule,
],
providers: [
// Global Guards: Alle Routen sind standardmaessig geschuetzt

View file

@ -101,6 +101,17 @@ class EnvironmentVariables {
@IsOptional()
@IsString()
AZURE_REDIRECT_URI?: string;
// Microsoft 365 Integration (separate Redirect URI fuer Graph-API-Scopes)
@IsOptional()
@IsString()
AZURE_INTEGRATION_REDIRECT_URI?: string;
// AES-256-GCM Schluessel fuer Token-Verschluesselung (64 Hex-Zeichen = 32 Bytes)
// Generieren: openssl rand -hex 32
@IsOptional()
@IsString()
INTEGRATION_ENCRYPTION_KEY?: string;
}
export function validateConfig(

View file

@ -14,6 +14,16 @@ import {
} from '@azure/msal-node';
import { RedisService } from '../../../redis/redis.service';
/** Token-Daten die nach Integration-Callback gespeichert werden */
export interface M365TokenResult {
accessToken: string;
/** Kann leer sein wenn MSAL keinen Refresh-Token zurueckgibt */
refreshToken: string;
expiresAt: Date;
scopes: string[];
msTenantId: string;
}
/**
* Informationen aus dem Microsoft ID-Token.
*/
@ -55,7 +65,14 @@ export class EntraIdService implements OnModuleInit {
private readonly logger = new Logger(EntraIdService.name);
private msalClient: ConfidentialClientApplication | null = null;
private redirectUri = '';
private integrationRedirectUri = '';
private readonly scopes = ['openid', 'profile', 'email', 'User.Read'];
private readonly integrationScopes = [
'offline_access',
'https://graph.microsoft.com/Mail.Read',
'https://graph.microsoft.com/Calendars.Read',
'https://graph.microsoft.com/Tasks.ReadWrite',
];
constructor(
private readonly config: ConfigService,
@ -123,6 +140,11 @@ export class EntraIdService implements OnModuleInit {
this.msalClient = new ConfidentialClientApplication(msalConfig);
this.redirectUri = ssoConfig.redirectUri;
// Integration-Redirect-URI aus Env (Fallback: SSO-URI + '/integration')
this.integrationRedirectUri =
this.config.get<string>('AZURE_INTEGRATION_REDIRECT_URI') ||
ssoConfig.redirectUri.replace('/callback', '/integration/callback');
}
/**
@ -296,4 +318,131 @@ export class EntraIdService implements OnModuleInit {
lastName,
};
}
// ── Microsoft 365 Integration (Graph API Scopes) ──────────────────────
/**
* Authorization-URL fuer MS365 Integration generieren.
* Verwendet erweiterte Scopes (Mail, Calendar, Tasks).
*/
async getIntegrationAuthUrl(state: string): Promise<string> {
if (!this.msalClient) {
throw new ServiceUnavailableException(
'Microsoft SSO ist nicht konfiguriert',
);
}
const authUrlRequest: AuthorizationUrlRequest = {
scopes: this.integrationScopes,
redirectUri: this.integrationRedirectUri,
state,
prompt: 'consent', // Immer Zustimmung anfordern fuer neue Scopes
};
return this.msalClient.getAuthCodeUrl(authUrlRequest);
}
/**
* Authorization Code gegen M365-Tokens tauschen.
* Gibt Access-Token, Refresh-Token und Ablaufzeit zurueck.
*/
async handleIntegrationCallback(code: string): Promise<M365TokenResult> {
if (!this.msalClient) {
throw new ServiceUnavailableException(
'Microsoft SSO ist nicht konfiguriert',
);
}
const tokenRequest: AuthorizationCodeRequest = {
code,
scopes: this.integrationScopes,
redirectUri: this.integrationRedirectUri,
};
const response: AuthenticationResult =
await this.msalClient.acquireTokenByCode(tokenRequest);
this.logger.log('M365 Integration Token erhalten');
// Refresh-Token aus MSAL-Antwort (erfordert offline_access Scope)
// In msal-node >= 2.x ist refreshToken in der Antwort verfuegbar
const rawResponse = response as AuthenticationResult & {
refreshToken?: string;
};
const refreshToken = rawResponse.refreshToken ?? '';
const tenantId =
(response.account?.tenantId) ?? '';
return {
accessToken: response.accessToken,
refreshToken,
expiresAt: response.expiresOn ?? new Date(Date.now() + 3600 * 1000),
scopes: this.integrationScopes,
msTenantId: tenantId,
};
}
/**
* Access-Token per Refresh-Token erneuern (Standard OAuth2 Token Endpoint).
* Wird von IntegrationsService aufgerufen wenn Token abgelaufen.
*/
async refreshIntegrationToken(
refreshToken: string,
): Promise<{ accessToken: string; expiresAt: Date; newRefreshToken?: string }> {
const ssoConfig = await this.loadConfigFromRedis();
const tenantId =
ssoConfig?.tenantId ?? this.config.get<string>('AZURE_TENANT_ID') ?? '';
const clientId =
ssoConfig?.clientId ?? this.config.get<string>('AZURE_CLIENT_ID') ?? '';
const clientSecret =
ssoConfig?.clientSecret ??
this.config.get<string>('AZURE_CLIENT_SECRET') ??
'';
if (!tenantId || !clientId || !clientSecret) {
throw new ServiceUnavailableException(
'Azure-Konfiguration fehlt fuer Token-Refresh',
);
}
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
scope: this.integrationScopes.join(' '),
});
const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const resp = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!resp.ok) {
const err = (await resp.json()) as { error_description?: string };
throw new Error(
`Token-Refresh fehlgeschlagen: ${err.error_description ?? resp.statusText}`,
);
}
const data = (await resp.json()) as {
access_token: string;
expires_in: number;
refresh_token?: string;
};
return {
accessToken: data.access_token,
expiresAt: new Date(Date.now() + data.expires_in * 1000),
newRefreshToken: data.refresh_token,
};
}
/** Integration-Redirect-URI zurueckgeben */
getIntegrationRedirectUri(): string {
return this.integrationRedirectUri;
}
}

View file

@ -0,0 +1,189 @@
import {
Controller,
Get,
Delete,
Query,
Res,
Logger,
UseGuards,
Param,
} 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 { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
import { RolesGuard } from '../../common/guards/roles.guard';
import { RedisService } from '../../redis/redis.service';
import { EntraIdService } from '../auth/sso/entra-id.service';
import { IntegrationsService } from './integrations.service';
interface JwtUser {
sub: string;
email: string;
role: string;
}
/**
* IntegrationsController
*
* Verwaltet Drittanbieter-OAuth-Verbindungen pro User.
* Derzeit: Microsoft 365 (Graph API Scopes).
*
* Routen:
* - GET /auth/integrations/microsoft-365 OAuth-Flow starten
* - GET /auth/integrations/microsoft-365/callback OAuth-Callback
* - GET /users/me/integrations Liste der Verbindungen
* - DELETE /users/me/integrations/microsoft-365 Verbindung trennen
* - GET /integrations/:userId/microsoft-365/token Intern: Token fuer CRM-Service
*/
@ApiTags('Integrations')
@Controller()
export class IntegrationsController {
private readonly logger = new Logger(IntegrationsController.name);
constructor(
private readonly integrationsService: IntegrationsService,
private readonly entraIdService: EntraIdService,
private readonly redis: RedisService,
private readonly config: ConfigService,
) {}
/**
* GET /api/v1/auth/integrations/microsoft-365
* OAuth2-Flow fuer Microsoft 365 starten.
* User muss eingeloggt sein userId wird im State gespeichert.
*/
@Get('auth/integrations/microsoft-365')
@ApiOperation({ summary: 'Microsoft 365 Integration starten' })
async initM365Integration(
@CurrentUser() user: JwtUser,
@Res() res: Response,
): Promise<void> {
// State = UUID, in Redis mit userId hinterlegen (5 Min TTL)
const state = uuidv4();
await this.redis.set(
`integration_state:${state}`,
user.sub,
300,
);
const authUrl = await this.entraIdService.getIntegrationAuthUrl(state);
this.logger.log(
`M365-Integration OAuth-Flow gestartet fuer User ${user.sub}`,
);
res.redirect(authUrl);
}
/**
* GET /api/v1/auth/integrations/microsoft-365/callback
* Callback von Microsoft Token speichern, redirect zum Profil.
*/
@Get('auth/integrations/microsoft-365/callback')
@Public()
@ApiOperation({ summary: 'Microsoft 365 Integration Callback' })
async m365Callback(
@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',
);
const successUrl = `${frontendUrl}/profile?integration=microsoft-365&status=success`;
const errorBase = `${frontendUrl}/profile?integration=microsoft-365&status=error`;
if (error) {
this.logger.warn(`M365-Integration Fehler: ${error}${errorDescription}`);
res.redirect(`${errorBase}&message=${encodeURIComponent(errorDescription || error)}`);
return;
}
if (!code || !state) {
res.redirect(`${errorBase}&message=Ungueltige+Antwort`);
return;
}
// State aus Redis laden (enthaelt userId)
const userId = await this.redis.get(`integration_state:${state}`);
if (!userId) {
this.logger.warn('M365-Integration State ungueltig oder abgelaufen');
res.redirect(`${errorBase}&message=Sitzung+abgelaufen`);
return;
}
await this.redis.del(`integration_state:${state}`);
try {
const tokenResult = await this.entraIdService.handleIntegrationCallback(code);
await this.integrationsService.saveM365Integration(userId, tokenResult);
this.logger.log(`M365-Integration erfolgreich fuer User ${userId}`);
res.redirect(successUrl);
} catch (err) {
this.logger.error(
`M365-Integration Callback Fehler: ${(err as Error).message}`,
);
res.redirect(
`${errorBase}&message=${encodeURIComponent('Verbindung fehlgeschlagen — bitte erneut versuchen')}`,
);
}
}
/**
* GET /api/v1/users/me/integrations
* Liste aller Drittanbieter-Verbindungen des eingeloggten Users.
*/
@Get('users/me/integrations')
@ApiOperation({ summary: 'Eigene Integrationen auflisten' })
async listMyIntegrations(@CurrentUser() user: JwtUser) {
const integrations = await this.integrationsService.listIntegrations(user.sub);
return { success: true, data: integrations };
}
/**
* DELETE /api/v1/users/me/integrations/microsoft-365
* Microsoft 365 Integration trennen.
*/
@Delete('users/me/integrations/microsoft-365')
@ApiOperation({ summary: 'Microsoft 365 Integration trennen' })
async disconnectM365(@CurrentUser() user: JwtUser) {
await this.integrationsService.removeM365Integration(user.sub);
return { success: true, message: 'Microsoft 365 Verbindung getrennt' };
}
/**
* GET /api/v1/integrations/:userId/microsoft-365/token
* INTERNER ENDPOINT fuer CRM-Service.
* Gibt den aktuellen M365-Access-Token zurueck (refreshed automatisch).
* Zugriff: nur PLATFORM_ADMIN oder eigener User.
*/
@Get('integrations/:userId/microsoft-365/token')
@ApiOperation({ summary: 'M365 Token fuer internen Dienst (CRM-Service)' })
async getTokenForService(
@Param('userId') userId: string,
@CurrentUser() caller: JwtUser,
) {
// Nur PLATFORM_ADMIN oder der User selbst darf diesen Endpoint nutzen
if (caller.role !== 'PLATFORM_ADMIN' && caller.sub !== userId) {
return { success: false, error: { message: 'Kein Zugriff' } };
}
const token = await this.integrationsService.getM365Token(userId);
return { success: true, data: token };
}
/**
* GET /api/v1/users/me/integrations/microsoft-365/token
* Eigenen M365-Token abrufen (fuer CRM-Service der den User-JWT weiterleitet).
*/
@Get('users/me/integrations/microsoft-365/token')
@ApiOperation({ summary: 'Eigenen M365 Token abrufen' })
async getMyM365Token(@CurrentUser() user: JwtUser) {
const token = await this.integrationsService.getM365Token(user.sub);
return { success: true, data: token };
}
}

View file

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { IntegrationsController } from './integrations.controller';
import { IntegrationsService } from './integrations.service';
import { PrismaModule } from '../../prisma/prisma.module';
import { RedisModule } from '../../redis/redis.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule, RedisModule, AuthModule],
controllers: [IntegrationsController],
providers: [IntegrationsService],
exports: [IntegrationsService],
})
export class IntegrationsModule {}

View file

@ -0,0 +1,207 @@
import {
Injectable,
Logger,
NotFoundException,
InternalServerErrorException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { EntraIdService, type M365TokenResult } from '../auth/sso/entra-id.service';
/** Antwort fuer den Frontend (ohne Tokens) */
export interface IntegrationInfo {
provider: 'MICROSOFT_365';
connected: boolean;
connectedAt?: string;
expiresAt?: string;
scopes?: string[];
}
/** Antwort fuer den internen Token-Endpoint (CRM-Service) */
export interface TokenResponse {
accessToken: string;
expiresAt: string;
provider: string;
}
const ALGORITHM = 'aes-256-gcm';
@Injectable()
export class IntegrationsService {
private readonly logger = new Logger(IntegrationsService.name);
private readonly encryptionKey: Buffer;
constructor(
private readonly prisma: PrismaService,
private readonly entraId: EntraIdService,
private readonly config: ConfigService,
) {
// Encryption Key aus Env (64 Hex-Zeichen = 32 Bytes)
const keyHex = this.config.get<string>('INTEGRATION_ENCRYPTION_KEY');
if (keyHex && keyHex.length === 64) {
this.encryptionKey = Buffer.from(keyHex, 'hex');
} else {
// Fallback: Deterministisch aus JWT-Key ableiten (Development only)
this.logger.warn(
'INTEGRATION_ENCRYPTION_KEY nicht konfiguriert — verwende unsicheren Fallback (nur fuer Entwicklung!)',
);
// Fallback-Key: 32 Bytes aus festem String (NIE in Produktion)
this.encryptionKey = Buffer.alloc(32, 'insight-dev-fallback-key-32bytes');
}
}
// ── Verschluesselung ──────────────────────────────────────────────────
private encrypt(text: string): string {
const iv = randomBytes(12); // 96-bit IV fuer GCM
const cipher = createCipheriv(ALGORITHM, this.encryptionKey, iv);
const encrypted = Buffer.concat([
cipher.update(text, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
// Format: iv:tag:encrypted (alle hex-kodiert, ':'-separiert)
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
}
private decrypt(encryptedText: string): string {
const parts = encryptedText.split(':');
if (parts.length !== 3) throw new Error('Ungueltig verschluesselter Wert');
const [ivHex, tagHex, dataHex] = parts;
const iv = Buffer.from(ivHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const data = Buffer.from(dataHex, 'hex');
const decipher = createDecipheriv(ALGORITHM, this.encryptionKey, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
}
// ── CRUD ──────────────────────────────────────────────────────────────
/**
* M365-Integration nach OAuth-Callback speichern.
*/
async saveM365Integration(userId: string, tokenResult: M365TokenResult): Promise<void> {
const data = {
provider: 'MICROSOFT_365',
accessToken: this.encrypt(tokenResult.accessToken),
refreshToken: this.encrypt(tokenResult.refreshToken || 'NONE'),
expiresAt: tokenResult.expiresAt,
scopes: tokenResult.scopes,
msTenantId: tokenResult.msTenantId || null,
};
await this.prisma.userIntegration.upsert({
where: { userId_provider: { userId, provider: 'MICROSOFT_365' } },
create: { userId, ...data },
update: data,
});
this.logger.log(`M365-Integration gespeichert fuer User ${userId}`);
}
/**
* M365-Token holen (automatisches Refresh wenn abgelaufen).
* Wird vom internen Token-Endpoint fuer den CRM-Service aufgerufen.
*/
async getM365Token(userId: string): Promise<TokenResponse> {
const integration = await this.prisma.userIntegration.findUnique({
where: { userId_provider: { userId, provider: 'MICROSOFT_365' } },
});
if (!integration) {
throw new NotFoundException(
'Keine Microsoft 365 Integration fuer diesen User',
);
}
const now = new Date();
const bufferMs = 5 * 60 * 1000; // 5 Minuten Puffer
// Token noch gueltig
if (integration.expiresAt.getTime() - now.getTime() > bufferMs) {
return {
accessToken: this.decrypt(integration.accessToken),
expiresAt: integration.expiresAt.toISOString(),
provider: 'MICROSOFT_365',
};
}
// Token abgelaufen — refresh
this.logger.log(`M365-Token abgelaufen fuer User ${userId} — refreshing`);
const refreshToken = this.decrypt(integration.refreshToken);
if (refreshToken === 'NONE' || !refreshToken) {
throw new InternalServerErrorException(
'Kein Refresh-Token vorhanden — bitte Microsoft 365 neu verbinden',
);
}
try {
const refreshed = await this.entraId.refreshIntegrationToken(refreshToken);
// Aktualisierte Tokens speichern
await this.prisma.userIntegration.update({
where: { userId_provider: { userId, provider: 'MICROSOFT_365' } },
data: {
accessToken: this.encrypt(refreshed.accessToken),
refreshToken: refreshed.newRefreshToken
? this.encrypt(refreshed.newRefreshToken)
: integration.refreshToken,
expiresAt: refreshed.expiresAt,
},
});
return {
accessToken: refreshed.accessToken,
expiresAt: refreshed.expiresAt.toISOString(),
provider: 'MICROSOFT_365',
};
} catch (err) {
this.logger.error(
`M365-Token-Refresh fehlgeschlagen fuer User ${userId}: ${(err as Error).message}`,
);
throw new InternalServerErrorException(
'Microsoft 365 Token konnte nicht erneuert werden — bitte neu verbinden',
);
}
}
/**
* Alle Integrationen eines Users auflisten (ohne Tokens).
*/
async listIntegrations(userId: string): Promise<IntegrationInfo[]> {
const integrations = await this.prisma.userIntegration.findMany({
where: { userId },
select: {
provider: true,
expiresAt: true,
scopes: true,
createdAt: true,
},
});
// M365 immer anzeigen (connected oder nicht)
const m365 = integrations.find((i) => i.provider === 'MICROSOFT_365');
return [
{
provider: 'MICROSOFT_365',
connected: !!m365,
connectedAt: m365?.createdAt.toISOString(),
expiresAt: m365?.expiresAt.toISOString(),
scopes: m365?.scopes ?? [],
},
];
}
/**
* M365-Integration trennen.
*/
async removeM365Integration(userId: string): Promise<void> {
await this.prisma.userIntegration.deleteMany({
where: { userId, provider: 'MICROSOFT_365' },
});
this.logger.log(`M365-Integration entfernt fuer User ${userId}`);
}
}