From 28f6ba84b044a6880a0ecf9b0abcb971b9d5dca0 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Thu, 12 Mar 2026 22:36:03 +0100 Subject: [PATCH] =?UTF-8?q?feat(core):=20Microsoft=20365=20OAuth-Integrati?= =?UTF-8?q?on=20=E2=80=94=20UserIntegration=20+=20IntegrationsModule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../core-service/prisma/core.schema.prisma | 26 +++ .../20260312_user_integrations/migration.sql | 22 ++ packages/core-service/src/app.module.ts | 2 + .../core-service/src/config/env.validation.ts | 11 + .../src/core/auth/sso/entra-id.service.ts | 149 +++++++++++++ .../integrations/integrations.controller.ts | 189 ++++++++++++++++ .../core/integrations/integrations.module.ts | 14 ++ .../core/integrations/integrations.service.ts | 207 ++++++++++++++++++ 8 files changed, 620 insertions(+) create mode 100644 packages/core-service/prisma/migrations/20260312_user_integrations/migration.sql create mode 100644 packages/core-service/src/core/integrations/integrations.controller.ts create mode 100644 packages/core-service/src/core/integrations/integrations.module.ts create mode 100644 packages/core-service/src/core/integrations/integrations.service.ts diff --git a/packages/core-service/prisma/core.schema.prisma b/packages/core-service/prisma/core.schema.prisma index 8e31890..1365b05 100644 --- a/packages/core-service/prisma/core.schema.prisma +++ b/packages/core-service/prisma/core.schema.prisma @@ -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 // -------------------------------------------------------- diff --git a/packages/core-service/prisma/migrations/20260312_user_integrations/migration.sql b/packages/core-service/prisma/migrations/20260312_user_integrations/migration.sql new file mode 100644 index 0000000..e613f4f --- /dev/null +++ b/packages/core-service/prisma/migrations/20260312_user_integrations/migration.sql @@ -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"); diff --git a/packages/core-service/src/app.module.ts b/packages/core-service/src/app.module.ts index 2a5b84c..d145bed 100644 --- a/packages/core-service/src/app.module.ts +++ b/packages/core-service/src/app.module.ts @@ -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 diff --git a/packages/core-service/src/config/env.validation.ts b/packages/core-service/src/config/env.validation.ts index 7cc154a..e5b99c2 100644 --- a/packages/core-service/src/config/env.validation.ts +++ b/packages/core-service/src/config/env.validation.ts @@ -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( 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 b105c70..d733e6d 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 @@ -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('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 { + 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 { + 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('AZURE_TENANT_ID') ?? ''; + const clientId = + ssoConfig?.clientId ?? this.config.get('AZURE_CLIENT_ID') ?? ''; + const clientSecret = + ssoConfig?.clientSecret ?? + this.config.get('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; + } } diff --git a/packages/core-service/src/core/integrations/integrations.controller.ts b/packages/core-service/src/core/integrations/integrations.controller.ts new file mode 100644 index 0000000..9ca7753 --- /dev/null +++ b/packages/core-service/src/core/integrations/integrations.controller.ts @@ -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 { + // 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 { + const frontendUrl = this.config.get( + '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 }; + } +} diff --git a/packages/core-service/src/core/integrations/integrations.module.ts b/packages/core-service/src/core/integrations/integrations.module.ts new file mode 100644 index 0000000..d42dcdc --- /dev/null +++ b/packages/core-service/src/core/integrations/integrations.module.ts @@ -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 {} diff --git a/packages/core-service/src/core/integrations/integrations.service.ts b/packages/core-service/src/core/integrations/integrations.service.ts new file mode 100644 index 0000000..fc50bad --- /dev/null +++ b/packages/core-service/src/core/integrations/integrations.service.ts @@ -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('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 { + 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 { + 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 { + 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 { + await this.prisma.userIntegration.deleteMany({ + where: { userId, provider: 'MICROSOFT_365' }, + }); + this.logger.log(`M365-Integration entfernt fuer User ${userId}`); + } +}