From 304bffefcf449cc21217c600048f58f0eaf1a2a6 Mon Sep 17 00:00:00 2001 From: George Djabarov <39195835+djabarovgeorge@users.noreply.github.com> Date: Mon, 4 May 2026 10:49:33 +0300 Subject: [PATCH 1/6] feat(api-service,dashboard): automate MS Teams onboarding fixes NV-7387 (#10958) --- .../app/agents/agents-webhook.controller.ts | 2 +- .../integrations/integrations.controller.ts | 204 ++- .../azure-setup-oauth-callback.command.ts | 20 + .../azure-setup-oauth-callback.usecase.ts | 802 +++++++++ .../msteams-oauth-callback.usecase.spec.ts | 196 +-- .../msteams-oauth-callback.usecase.ts | 57 +- .../generate-azure-setup-oauth-url.command.ts | 8 + .../generate-azure-setup-oauth-url.usecase.ts | 120 ++ .../generate-msteams-oauth-url.usecase.ts | 4 +- .../generate-msteams-arm-template.command.ts | 8 + .../generate-msteams-arm-template.usecase.ts | 87 + .../get-msteams-arm-template.usecase.ts | 250 +++ .../src/app/integrations/usecases/index.ts | 10 + .../msteams-health-check.command.ts | 20 + .../msteams-health-check.usecase.ts | 214 +++ apps/dashboard/src/api/integrations.ts | 48 + .../agents/agent-integrations-tab.tsx | 3 +- .../components/agents/provider-dropdown.tsx | 8 +- .../components/agents/teams-app-package.ts | 69 +- .../components/agents/teams-setup-guide.tsx | 1494 +++++++++++++++-- apps/dashboard/src/utils/build-zip.ts | 69 + .../src/utils/build-zip.ts | 86 + libs/application-generic/src/utils/index.ts | 3 +- .../integration/integration.entity.ts | 11 + .../integration/integration.schema.ts | 10 + packages/shared/src/types/feature-flags.ts | 2 + 26 files changed, 3396 insertions(+), 409 deletions(-) create mode 100644 apps/api/src/app/integrations/usecases/azure-setup-oauth-callback/azure-setup-oauth-callback.command.ts create mode 100644 apps/api/src/app/integrations/usecases/azure-setup-oauth-callback/azure-setup-oauth-callback.usecase.ts create mode 100644 apps/api/src/app/integrations/usecases/generate-azure-setup-oauth-url/generate-azure-setup-oauth-url.command.ts create mode 100644 apps/api/src/app/integrations/usecases/generate-azure-setup-oauth-url/generate-azure-setup-oauth-url.usecase.ts create mode 100644 apps/api/src/app/integrations/usecases/generate-msteams-arm-template/generate-msteams-arm-template.command.ts create mode 100644 apps/api/src/app/integrations/usecases/generate-msteams-arm-template/generate-msteams-arm-template.usecase.ts create mode 100644 apps/api/src/app/integrations/usecases/generate-msteams-arm-template/get-msteams-arm-template.usecase.ts create mode 100644 apps/api/src/app/integrations/usecases/msteams-health-check/msteams-health-check.command.ts create mode 100644 apps/api/src/app/integrations/usecases/msteams-health-check/msteams-health-check.usecase.ts create mode 100644 apps/dashboard/src/utils/build-zip.ts create mode 100644 libs/application-generic/src/utils/build-zip.ts diff --git a/apps/api/src/app/agents/agents-webhook.controller.ts b/apps/api/src/app/agents/agents-webhook.controller.ts index 32abbfea6fa..7610d18cbea 100644 --- a/apps/api/src/app/agents/agents-webhook.controller.ts +++ b/apps/api/src/app/agents/agents-webhook.controller.ts @@ -94,7 +94,7 @@ export class AgentsWebhookController { if (err instanceof HttpException) { res.status(err.getStatus()).json(err.getResponse()); } else { - res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ error: 'Internal server error' }); + throw err; } } } diff --git a/apps/api/src/app/integrations/integrations.controller.ts b/apps/api/src/app/integrations/integrations.controller.ts index 40e7f6fdb66..c21826bdd5e 100644 --- a/apps/api/src/app/integrations/integrations.controller.ts +++ b/apps/api/src/app/integrations/integrations.controller.ts @@ -22,6 +22,7 @@ import { GetDecryptedIntegrations, IntegrationResponseDto, OtelSpan, + PinoLogger, RequirePermissions, } from '@novu/application-generic'; import { CommunityOrganizationRepository } from '@novu/dal'; @@ -55,23 +56,35 @@ import { ChannelTypeLimitDto } from './dtos/get-channel-type-limit.sto'; import { UpdateIntegrationRequestDto } from './dtos/update-integration.dto'; import { AutoConfigureIntegrationCommand } from './usecases/auto-configure-integration/auto-configure-integration.command'; import { AutoConfigureIntegration } from './usecases/auto-configure-integration/auto-configure-integration.usecase'; +import { AzureSetupOauthCallbackCommand } from './usecases/azure-setup-oauth-callback/azure-setup-oauth-callback.command'; +import { AzureSetupOauthCallback } from './usecases/azure-setup-oauth-callback/azure-setup-oauth-callback.usecase'; import { ChatOauthCallbackCommand } from './usecases/chat-oauth-callback/chat-oauth-callback.command'; import { ResponseTypeEnum } from './usecases/chat-oauth-callback/chat-oauth-callback.response'; import { ChatOauthCallback } from './usecases/chat-oauth-callback/chat-oauth-callback.usecase'; import { CreateIntegrationCommand } from './usecases/create-integration/create-integration.command'; import { CreateIntegration } from './usecases/create-integration/create-integration.usecase'; +import { GenerateAzureSetupOauthUrlCommand } from './usecases/generate-azure-setup-oauth-url/generate-azure-setup-oauth-url.command'; +import { GenerateAzureSetupOauthUrl } from './usecases/generate-azure-setup-oauth-url/generate-azure-setup-oauth-url.usecase'; import { GenerateChatOauthUrlCommand } from './usecases/generate-chat-oath-url/generate-chat-oauth-url.command'; import { GenerateChatOauthUrl } from './usecases/generate-chat-oath-url/generate-chat-oauth-url.usecase'; import { GenerateConnectOauthUrlCommand } from './usecases/generate-chat-oath-url/generate-connect-oauth-url.command'; import { GenerateConnectOauthUrl } from './usecases/generate-chat-oath-url/generate-connect-oauth-url.usecase'; import { GenerateLinkUserOauthUrlCommand } from './usecases/generate-chat-oath-url/generate-link-user-oauth-url.command'; import { GenerateLinkUserOauthUrl } from './usecases/generate-chat-oath-url/generate-link-user-oauth-url.usecase'; +import { GenerateMsTeamsArmTemplateCommand } from './usecases/generate-msteams-arm-template/generate-msteams-arm-template.command'; +import { GenerateMsTeamsArmTemplate } from './usecases/generate-msteams-arm-template/generate-msteams-arm-template.usecase'; +import { GetMsTeamsArmTemplate } from './usecases/generate-msteams-arm-template/get-msteams-arm-template.usecase'; import { GetInAppActivatedCommand } from './usecases/get-in-app-activated/get-in-app-activated.command'; import { GetInAppActivated } from './usecases/get-in-app-activated/get-in-app-activated.usecase'; import { GetIntegrationsCommand } from './usecases/get-integrations/get-integrations.command'; import { GetIntegrations } from './usecases/get-integrations/get-integrations.usecase'; import { GetWebhookSupportStatusCommand } from './usecases/get-webhook-support-status/get-webhook-support-status.command'; import { GetWebhookSupportStatus } from './usecases/get-webhook-support-status/get-webhook-support-status.usecase'; +import { MsTeamsHealthCheckCommand } from './usecases/msteams-health-check/msteams-health-check.command'; +import { + MsTeamsHealthCheck, + MsTeamsHealthCheckResult, +} from './usecases/msteams-health-check/msteams-health-check.usecase'; import { RemoveIntegrationCommand } from './usecases/remove-integration/remove-integration.command'; import { RemoveIntegration } from './usecases/remove-integration/remove-integration.usecase'; import { SetIntegrationAsPrimaryCommand } from './usecases/set-integration-as-primary/set-integration-as-primary.command'; @@ -100,8 +113,16 @@ export class IntegrationsController { private generateConnectOauthUrlUsecase: GenerateConnectOauthUrl, private generateLinkUserOauthUrlUsecase: GenerateLinkUserOauthUrl, private chatOauthCallbackUsecase: ChatOauthCallback, - private featureFlagsService: FeatureFlagsService - ) {} + private featureFlagsService: FeatureFlagsService, + private generateMsTeamsArmTemplateUsecase: GenerateMsTeamsArmTemplate, + private getMsTeamsArmTemplateUsecase: GetMsTeamsArmTemplate, + private generateAzureSetupOauthUrlUsecase: GenerateAzureSetupOauthUrl, + private azureSetupOauthCallbackUsecase: AzureSetupOauthCallback, + private msTeamsHealthCheckUsecase: MsTeamsHealthCheck, + private logger: PinoLogger + ) { + this.logger.setContext(IntegrationsController.name); + } @Get('/') @ApiOkResponse({ @@ -412,6 +433,185 @@ export class IntegrationsController { ); } + @Get('/:integrationId/msteams-arm-template/deploy-url') + @ApiOkResponse({ + description: 'Signed Azure Portal "Deploy to Azure" URL for the MS Teams ARM template.', + }) + @ApiOperation({ + summary: 'Get MS Teams ARM template deploy URL', + description: + 'Returns a short-lived signed URL that opens the Azure Portal with a pre-filled ARM template to create the Azure Bot resource and enable the MS Teams channel.', + }) + @ApiExcludeEndpoint() + @RequireAuthentication() + @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) + async getMsTeamsArmTemplateDeployUrl( + @UserSession() user: UserSessionData, + @Param('integrationId') integrationId: string + ): Promise<{ deployUrl: string }> { + return this.generateMsTeamsArmTemplateUsecase.execute( + GenerateMsTeamsArmTemplateCommand.create({ + userId: user._id, + organizationId: user.organizationId, + integrationId, + }) + ); + } + + /** + * Public endpoint fetched by Azure Portal when the user clicks "Deploy to Azure". + * Protected by an HMAC-signed, time-expiring `sig` + `exp` query parameter pair — + * no session cookie is available because Azure's servers make this request, not the browser. + */ + @Get('/:integrationId/msteams-arm-template') + @ApiExcludeEndpoint() + @ApiOperation({ summary: 'Serve MS Teams ARM template JSON (signed)' }) + async getMsTeamsArmTemplateJson( + @Res() res: Response, + @Param('integrationId') integrationId: string, + @Query('sig') sig: string, + @Query('exp') exp: string + ): Promise { + if (!sig || !exp) { + throw new BadRequestException('Missing required parameters: sig, exp'); + } + + const { template } = await this.getMsTeamsArmTemplateUsecase.execute(integrationId, sig, exp); + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.send(JSON.stringify(template, null, 2)); + } + + /** + * Quick Setup: generate an Azure AD OAuth URL so Novu can create the App Registration + * on the user's behalf via Microsoft Graph. + */ + @Get('/:integrationId/msteams-azure-setup/oauth-url') + @ApiOkResponse({ + description: 'Azure AD OAuth URL for the Quick Setup flow (Novu creates the app registration).', + }) + @ApiOperation({ + summary: 'Get Azure Quick Setup OAuth URL', + description: + 'Returns an Azure AD OAuth URL that authorizes Novu to create an App Registration and client secret on your behalf via Microsoft Graph.', + }) + @ApiExcludeEndpoint() + @RequireAuthentication() + @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE) + async getAzureSetupOauthUrl( + @UserSession() user: UserSessionData, + @Param('integrationId') integrationId: string + ): Promise<{ url: string }> { + const url = await this.generateAzureSetupOauthUrlUsecase.execute( + GenerateAzureSetupOauthUrlCommand.create({ + userId: user._id, + organizationId: user.organizationId, + environmentId: user.environmentId, + integrationId, + }) + ); + + return { url }; + } + + /** + * Health-check endpoint polled by the dashboard to determine if the saved MS Teams + * credentials, app catalog entry, and Graph permissions are ready after the Quick + * Setup OAuth flow. + */ + @Get('/:integrationId/msteams-health') + @ApiOkResponse({ + description: 'Per-checkpoint health status for an MS Teams integration after Quick Setup.', + }) + @ApiOperation({ + summary: 'Get MS Teams integration health status', + description: + 'Returns the readiness status of the stored MS Teams credentials, app catalog entry, and Graph permissions. Poll this endpoint after the OAuth setup completes to determine when it is safe to proceed to admin consent.', + }) + @ApiExcludeEndpoint() + @RequireAuthentication() + @RequirePermissions(PermissionsEnum.INTEGRATION_READ) + async getMsTeamsHealth( + @UserSession() user: UserSessionData, + @Param('integrationId') integrationId: string, + @Query('checks') checksParam?: string + ): Promise { + const checks = checksParam + ? checksParam + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : undefined; + + return this.msTeamsHealthCheckUsecase.execute( + MsTeamsHealthCheckCommand.create({ + environmentId: user.environmentId, + organizationId: user.organizationId, + integrationId, + checks, + }) + ); + } + + /** + * Quick Setup callback: Azure AD redirects here after the user authorizes Novu. + * Creates the App Registration, secret, and service principal via Graph, saves + * credentials to the integration, then attempts to upload the Teams app to the catalog. + * Returns a self-closing script that posts a message to the opener tab and closes itself. + */ + @Get('/chat/oauth/azure-setup/callback') + @ApiExcludeEndpoint() + @ApiOperation({ summary: 'Azure Quick Setup OAuth callback' }) + async handleAzureSetupOauthCallback( + @Res() res: Response, + @Query('code') code?: string, + @Query('state') state?: string, + @Query('error') error?: string, + @Query('error_description') errorDescription?: string + ): Promise { + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'"); + + if (!state) { + res + .status(400) + .type('html') + .send( + AzureSetupOauthCallback.buildPopupHtml({ + success: false, + errorMessage: 'Missing required OAuth parameter: state', + }) + ); + + return; + } + + try { + const result = await this.azureSetupOauthCallbackUsecase.execute( + AzureSetupOauthCallbackCommand.create({ + state, + code, + error, + errorDescription, + }) + ); + + res.type('html').send(result.html); + } catch (err: unknown) { + this.logger.error({ err }, 'Azure OAuth callback failed'); + + res + .status(200) + .type('html') + .send( + AzureSetupOauthCallback.buildPopupHtml({ + success: false, + errorMessage: 'An unexpected error occurred while completing Azure setup.', + }) + ); + } + } + /** * @deprecated Use POST /integrations/channel-connections/oauth or POST /integrations/channel-endpoints/oauth instead. */ diff --git a/apps/api/src/app/integrations/usecases/azure-setup-oauth-callback/azure-setup-oauth-callback.command.ts b/apps/api/src/app/integrations/usecases/azure-setup-oauth-callback/azure-setup-oauth-callback.command.ts new file mode 100644 index 00000000000..911552e0d0e --- /dev/null +++ b/apps/api/src/app/integrations/usecases/azure-setup-oauth-callback/azure-setup-oauth-callback.command.ts @@ -0,0 +1,20 @@ +import { BaseCommand } from '@novu/application-generic'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class AzureSetupOauthCallbackCommand extends BaseCommand { + @IsNotEmpty() + @IsString() + readonly state: string; + + @IsOptional() + @IsString() + readonly code?: string; + + @IsOptional() + @IsString() + readonly error?: string; + + @IsOptional() + @IsString() + readonly errorDescription?: string; +} diff --git a/apps/api/src/app/integrations/usecases/azure-setup-oauth-callback/azure-setup-oauth-callback.usecase.ts b/apps/api/src/app/integrations/usecases/azure-setup-oauth-callback/azure-setup-oauth-callback.usecase.ts new file mode 100644 index 00000000000..e631970b59b --- /dev/null +++ b/apps/api/src/app/integrations/usecases/azure-setup-oauth-callback/azure-setup-oauth-callback.usecase.ts @@ -0,0 +1,802 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { buildZip, createHash, encryptCredentials, PinoLogger } from '@novu/application-generic'; +import { AgentIntegrationRepository, EnvironmentRepository, IntegrationRepository } from '@novu/dal'; +import axios, { AxiosError } from 'axios'; +import { + AZURE_SETUP_OAUTH_SCOPES, + AzureSetupStateData, + GenerateAzureSetupOauthUrl, +} from '../generate-azure-setup-oauth-url/generate-azure-setup-oauth-url.usecase'; +import { splitOAuthState } from '../generate-chat-oath-url/chat-oauth-state.util'; +import { AzureSetupOauthCallbackCommand } from './azure-setup-oauth-callback.command'; + +const MS_GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0'; +const MS_LOGIN_BASE_URL = 'https://login.microsoftonline.com'; +const MS_ARM_BASE_URL = 'https://management.azure.com'; +const MS_ARM_API_VERSION = '2022-09-01'; +const MS_BOT_SERVICE_API_VERSION = '2023-09-15-preview'; + +/** + * Graph permissions required on the customer's bot app registration. + * These match what the MS Teams integration manual setup documents. + */ +const REQUIRED_GRAPH_PERMISSIONS = [ + { id: '7ab1d382-f21e-4acd-a863-ba3e13f7da61', type: 'Role' }, // Directory.Read.All + { id: '2280dda6-0bfd-44ee-a2f4-cb867cfc4c1e', type: 'Role' }, // Team.ReadBasic.All + { id: '59a6b24b-4225-4393-8165-ebaec5f55d7a', type: 'Role' }, // Channel.ReadBasic.All + { id: 'e12dae10-5a57-4817-b79d-dfbec5348930', type: 'Role' }, // AppCatalog.Read.All + { id: '9f67436c-5415-4e7f-8ac1-3014a7132630', type: 'Role' }, // TeamsAppInstallation.ReadWriteSelfForTeam.All + { id: '908de74d-f8b2-4d6b-a9ed-2a17b3b78179', type: 'Role' }, // TeamsAppInstallation.ReadWriteSelfForUser.All +]; + +/** Graph resource ID for Microsoft Graph (constant) */ +const GRAPH_RESOURCE_APP_ID = '00000003-0000-0000-c000-000000000000'; + +export type AzureSetupResult = { + /** Script response for the browser popup. Posts a message to the opener and closes the tab. */ + html: string; +}; + +type TokenResponse = { + accessToken: string; + refreshToken: string | null; +}; + +@Injectable() +export class AzureSetupOauthCallback { + constructor( + private integrationRepository: IntegrationRepository, + private environmentRepository: EnvironmentRepository, + private agentIntegrationRepository: AgentIntegrationRepository, + private logger: PinoLogger + ) { + this.logger.setContext(AzureSetupOauthCallback.name); + } + + async execute(command: AzureSetupOauthCallbackCommand): Promise { + if (command.error) { + this.logger.error( + `Azure OAuth callback returned an error: error=${command.error} description=${command.errorDescription ?? 'n/a'}` + ); + throw new BadRequestException( + `Azure OAuth error: ${command.error}${command.errorDescription ? ` — ${command.errorDescription}` : ''}` + ); + } + + if (!command.code) { + throw new BadRequestException('Missing authorization code from Azure OAuth callback'); + } + + const stateData = await this.decodeAndVerifyState(command.state); + + this.logger.info( + `Azure setup OAuth callback: creating app registration for integrationId=${stateData.integrationId} organizationId=${stateData.organizationId}` + ); + + const { accessToken, refreshToken } = await this.exchangeCodeForToken(command.code); + + const { appId, secretValue, tenantId } = await this.createAppRegistration(accessToken, stateData); + + this.logger.info( + `Azure setup: app registration created appId=${appId} tenantId=${tenantId} integrationId=${stateData.integrationId}` + ); + + await this.saveCredentials(stateData, appId, secretValue, tenantId); + + this.logger.info(`Azure setup: credentials saved for integrationId=${stateData.integrationId}`); + + await this.tryUploadTeamsApp(accessToken, appId, stateData); + + // Fire-and-forget: deploy the Bot Service via ARM (health-check polling catches readiness) + if (refreshToken) { + void this.tryDeployBotService(refreshToken, appId, tenantId, stateData).catch((err) => { + this.logger.warn( + `Azure setup: ARM deployment failed (non-fatal, health check will reflect) integrationId=${stateData.integrationId} error="${this.axiosErrorMessage(err)}" responseBody=${JSON.stringify((err as AxiosError)?.response?.data ?? null)}` + ); + }); + } else { + this.logger.warn( + `Azure setup: no refresh token available, skipping ARM deployment integrationId=${stateData.integrationId}` + ); + void this.writeProvisioning(stateData, { + status: 'failed', + completedAt: new Date().toISOString(), + errorMessage: 'No refresh token available — ARM deployment was skipped.', + }); + } + + return { html: AzureSetupOauthCallback.buildPopupHtml({ success: true }) }; + } + + static buildPopupHtml(_options: { success: boolean; errorMessage?: string }): string { + return `