Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c22f60d
feat(api, dashboard, ui): enhance MS Teams integration with user link…
djabarovgeorge Apr 26, 2026
450fa38
feat(api, worker): integrate MsTeamsTokenService for improved MS Team…
djabarovgeorge Apr 26, 2026
23b699b
refactor(worker): remove debug logging from send-message use case
djabarovgeorge Apr 26, 2026
45851b4
enhance(MsTeamsOauthCallback): refine app ID resolution with distribu…
djabarovgeorge Apr 26, 2026
5f749df
refactor(TeamsSetupGuide): update OAuth callback URL handling and enh…
djabarovgeorge Apr 26, 2026
b9e6ab9
fix(MsTeamsOAuth): improve error handling and HTML response for missi…
djabarovgeorge Apr 27, 2026
ed1fd53
fix(MsTeamsLinkUser): improve OAuth URL handling and popup management
djabarovgeorge Apr 27, 2026
2b4ea79
fix(MsTeamsLinkUser): streamline OAuth URL handling by removing popup…
djabarovgeorge Apr 27, 2026
ad188d4
feat(MsTeamsOAuth): add autoLinkUser feature for streamlined user lin…
djabarovgeorge Apr 27, 2026
408dd08
feat(OAuth): enhance autoLinkUser functionality across integrations
djabarovgeorge Apr 27, 2026
e40487f
feat(OAuth): introduce new endpoints for connect and link user OAuth …
djabarovgeorge Apr 27, 2026
bea36c4
test(OAuth): improve error handling in MsTeams OAuth URL generation t…
djabarovgeorge Apr 27, 2026
70917a0
test(MsTeamsOauthCallback): enhance error handling in OAuth callback …
djabarovgeorge Apr 27, 2026
705a1b9
test(MsTeamsOauthCallback): refactor tests to improve response valida…
djabarovgeorge Apr 27, 2026
6af03dc
refactor(MsTeamsConnectButton & SlackConnectButton): streamline integ…
djabarovgeorge Apr 27, 2026
b00843f
chore: update playground
djabarovgeorge Apr 27, 2026
d5571cf
fix(OAuth): update OAuth URL generation to use dynamic base URL
djabarovgeorge Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions apps/api/src/app/inbox/inbox.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,14 @@ import { ParseEventRequestMulticastCommand } from '../events/usecases/parse-even
import { ParseEventRequest } from '../events/usecases/parse-event-request/parse-event-request.usecase';
import { GenerateChatOauthUrlRequestDto } from '../integrations/dtos/generate-chat-oauth-url.dto';
import { GenerateChatOAuthUrlResponseDto } from '../integrations/dtos/generate-chat-oauth-url-response.dto';
import { GenerateConnectOauthUrlRequestDto } from '../integrations/dtos/generate-connect-oauth-url-request.dto';
import { GenerateLinkUserOauthUrlRequestDto } from '../integrations/dtos/generate-link-user-oauth-url-request.dto';
import { GenerateChatOauthUrlCommand } from '../integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.command';
import { GenerateChatOauthUrl } from '../integrations/usecases/generate-chat-oath-url/generate-chat-oauth-url.usecase';
import { GenerateConnectOauthUrlCommand } from '../integrations/usecases/generate-chat-oath-url/generate-connect-oauth-url.command';
import { GenerateConnectOauthUrl } from '../integrations/usecases/generate-chat-oath-url/generate-connect-oauth-url.usecase';
import { GenerateLinkUserOauthUrlCommand } from '../integrations/usecases/generate-chat-oath-url/generate-link-user-oauth-url.command';
import { GenerateLinkUserOauthUrl } from '../integrations/usecases/generate-chat-oath-url/generate-link-user-oauth-url.usecase';
import { ExcludeFromIdempotency } from '../shared/framework/exclude-from-idempotency';
import { ApiCommonResponses } from '../shared/framework/response.decorator';
import { KeylessAccessible } from '../shared/framework/swagger/keyless.security';
Expand Down Expand Up @@ -138,6 +144,8 @@ export class InboxController {
private getChannelEndpointUsecase: GetChannelEndpoint,
private deleteChannelEndpointUsecase: DeleteChannelEndpoint,
private generateChatOauthUrlUsecase: GenerateChatOauthUrl,
private generateConnectOauthUrlUsecase: GenerateConnectOauthUrl,
private generateLinkUserOauthUrlUsecase: GenerateLinkUserOauthUrl,
private featureFlagsService: FeatureFlagsService
) {}

Expand Down Expand Up @@ -816,25 +824,47 @@ export class InboxController {
}

@UseGuards(AuthGuard('subscriberJwt'))
@Post('/chat/oauth')
async generateChatOAuthUrl(
@Post('/channel-connections/oauth')
async generateConnectOAuthUrl(
@SubscriberSession() subscriberSession: SubscriberSession,
@Body() body: GenerateChatOauthUrlRequestDto
@Body() body: GenerateConnectOauthUrlRequestDto
): Promise<GenerateChatOAuthUrlResponseDto> {
await this.checkChannelFeatureEnabled(subscriberSession._organizationId);

const url = await this.generateChatOauthUrlUsecase.execute(
GenerateChatOauthUrlCommand.create({
const url = await this.generateConnectOauthUrlUsecase.execute(
GenerateConnectOauthUrlCommand.create({
environmentId: subscriberSession._environmentId,
organizationId: subscriberSession._organizationId,
subscriberId: subscriberSession.subscriberId,
integrationIdentifier: body.integrationIdentifier,
connectionIdentifier: body.connectionIdentifier,
context: body.context,
scope: body.scope,
userScope: body.userScope,
mode: body.mode,
connectionMode: body.connectionMode,
autoLinkUser: body.autoLinkUser,
})
);

return { url };
}

@UseGuards(AuthGuard('subscriberJwt'))
@Post('/channel-endpoints/oauth')
async generateLinkUserOAuthUrl(
@SubscriberSession() subscriberSession: SubscriberSession,
@Body() body: GenerateLinkUserOauthUrlRequestDto
): Promise<GenerateChatOAuthUrlResponseDto> {
await this.checkChannelFeatureEnabled(subscriberSession._organizationId);

const url = await this.generateLinkUserOauthUrlUsecase.execute(
GenerateLinkUserOauthUrlCommand.create({
environmentId: subscriberSession._environmentId,
organizationId: subscriberSession._organizationId,
subscriberId: subscriberSession.subscriberId,
integrationIdentifier: body.integrationIdentifier,
connectionIdentifier: body.connectionIdentifier,
context: body.context,
userScope: body.userScope,
})
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiContextPayload, IsValidContextPayload } from '@novu/application-generic';
import { ConnectionMode, ContextPayload } from '@novu/shared';
import { IsArray, IsDefined, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsArray, IsBoolean, IsDefined, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import {
OAuthMode,
SLACK_DEFAULT_OAUTH_SCOPES,
SLACK_LINK_USER_OAUTH_SCOPES,
} from '../usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase';

/**
* @deprecated Use GenerateConnectOauthUrlRequestDto (POST /channel-connections/oauth) or
* GenerateLinkUserOauthUrlRequestDto (POST /channel-endpoints/oauth) instead.
*/
export class GenerateChatOauthUrlRequestDto {
@ApiProperty({
type: String,
Expand Down Expand Up @@ -113,4 +117,20 @@ export class GenerateChatOauthUrlRequestDto {
@IsString()
@IsIn(['subscriber', 'shared'])
connectionMode?: ConnectionMode;

@ApiPropertyOptional({
type: Boolean,
description:
'When true, after the workspace/tenant connection is created the OAuth flow also links the subscriber ' +
'who clicked "Connect" as a personal endpoint. ' +
'For Slack, this uses the authed_user.id already returned by oauth.v2.access — no extra redirect. ' +
'For MS Teams, this triggers a second OAuth redirect for delegated user-identity consent. ' +
'Defaults to false when omitted; the SlackConnectButton and MsTeamsConnectButton SDK components ' +
'default this to true.',
example: true,
required: false,
})
@IsOptional()
@IsBoolean()
autoLinkUser?: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiContextPayload, IsValidContextPayload } from '@novu/application-generic';
import { ConnectionMode, ContextPayload } from '@novu/shared';
import { IsArray, IsBoolean, IsDefined, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { SLACK_DEFAULT_OAUTH_SCOPES } from '../usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase';

export class GenerateConnectOauthUrlRequestDto {
@ApiPropertyOptional({
type: String,
description:
'The subscriber ID to associate with the channel connection. ' +
'For Slack: optional for workspace connections (required only for incoming-webhook scope). ' +
'For MS Teams: optional. Admin consent is tenant-wide.',
example: 'subscriber-123',
})
@IsOptional()
@IsString()
subscriberId?: string;

@ApiProperty({
type: String,
description: 'Integration identifier',
})
@IsString()
@IsDefined()
@IsNotEmpty({ message: 'Integration identifier is required' })
integrationIdentifier: string;

@ApiPropertyOptional({
type: String,
description: 'Identifier of the channel connection that will be created. Generated automatically if not provided.',
example: 'slack-connection-abc123',
})
@IsString()
@IsOptional()
connectionIdentifier?: string;

@ApiContextPayload()
@IsOptional()
@IsValidContextPayload({ maxCount: 5 })
context?: ContextPayload;

@ApiPropertyOptional({
type: [String],
description:
`**Slack only**: OAuth scopes to request during authorization. ` +
`If not specified, default scopes will be used: ${SLACK_DEFAULT_OAUTH_SCOPES.join(', ')}. ` +
`**MS Teams**: ignored — uses admin consent with pre-configured Azure AD permissions.`,
example: ['chat:write', 'chat:write.public', 'channels:read'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
scope?: string[];

@ApiPropertyOptional({
type: String,
description:
'Connection mode that determines how the channel connection is scoped. ' +
'"subscriber" (default) associates the connection with a specific subscriber. ' +
'"shared" associates the connection with a context instead of a subscriber.',
enum: ['subscriber', 'shared'],
example: 'shared',
})
@IsOptional()
@IsString()
@IsIn(['subscriber', 'shared'])
connectionMode?: ConnectionMode;

@ApiPropertyOptional({
type: Boolean,
description:
'When true (default when connectionMode is "subscriber"), after the workspace/tenant connection is created ' +
'the OAuth flow also links the subscriber who clicked "Connect" as a personal endpoint. ' +
'For Slack, uses the authed_user.id returned by oauth.v2.access — no extra redirect. ' +
'For MS Teams, triggers a second OAuth redirect for delegated user-identity consent. ' +
'Set to false to only create the workspace connection without linking the individual user.',
example: true,
})
@IsOptional()
@IsBoolean()
autoLinkUser?: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiContextPayload, IsValidContextPayload } from '@novu/application-generic';
import { ContextPayload } from '@novu/shared';
import { IsArray, IsDefined, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { SLACK_LINK_USER_OAUTH_SCOPES } from '../usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase';

export class GenerateLinkUserOauthUrlRequestDto {
@ApiProperty({
type: String,
description:
'The subscriber ID to link to their chat identity. Required — this operation always binds ' +
'a specific subscriber to a user identity in the chat provider.',
example: 'subscriber-123',
})
@IsString()
@IsDefined()
@IsNotEmpty({ message: 'subscriberId is required for link_user' })
subscriberId: string;

@ApiProperty({
type: String,
description: 'Integration identifier',
})
@IsString()
@IsDefined()
@IsNotEmpty({ message: 'Integration identifier is required' })
integrationIdentifier: string;

@ApiPropertyOptional({
type: String,
description:
'Identifier of the existing channel connection to associate this user endpoint with. ' +
'Generated automatically if not provided.',
example: 'slack-connection-abc123',
})
@IsString()
@IsOptional()
connectionIdentifier?: string;

@ApiContextPayload()
@IsOptional()
@IsValidContextPayload({ maxCount: 5 })
context?: ContextPayload;

@ApiPropertyOptional({
type: [String],
description:
`**Slack only**: User-level OAuth scopes for "Sign in with Slack". ` +
`Defaults to: ${SLACK_LINK_USER_OAUTH_SCOPES.join(', ')}. ` +
`**MS Teams**: ignored — uses delegated OpenID scopes (openid, profile, User.Read).`,
example: ['identity.basic'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
userScope?: string[];
}
82 changes: 81 additions & 1 deletion apps/api/src/app/integrations/integrations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import { AutoConfigureIntegrationResponseDto } from './dtos/auto-configure-integ
import { CreateIntegrationRequestDto } from './dtos/create-integration-request.dto';
import { GenerateChatOauthUrlRequestDto } from './dtos/generate-chat-oauth-url.dto';
import { GenerateChatOAuthUrlResponseDto } from './dtos/generate-chat-oauth-url-response.dto';
import { GenerateConnectOauthUrlRequestDto } from './dtos/generate-connect-oauth-url-request.dto';
import { GenerateLinkUserOauthUrlRequestDto } from './dtos/generate-link-user-oauth-url-request.dto';
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';
Expand All @@ -61,6 +63,10 @@ import { CreateIntegrationCommand } from './usecases/create-integration/create-i
import { CreateIntegration } from './usecases/create-integration/create-integration.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 { 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';
Expand Down Expand Up @@ -92,6 +98,8 @@ export class IntegrationsController {
private calculateLimitNovuIntegration: CalculateLimitNovuIntegration,
private organizationRepository: CommunityOrganizationRepository,
private generateChatOauthUrlUsecase: GenerateChatOauthUrl,
private generateConnectOauthUrlUsecase: GenerateConnectOauthUrl,
private generateLinkUserOauthUrlUsecase: GenerateLinkUserOauthUrl,
private chatOauthCallbackUsecase: ChatOauthCallback,
private featureFlagsService: FeatureFlagsService
) {}
Expand Down Expand Up @@ -405,13 +413,18 @@ export class IntegrationsController {
);
}

/**
* @deprecated Use POST /integrations/channel-connections/oauth or POST /integrations/channel-endpoints/oauth instead.
*/
@Post('/chat/oauth')
@ApiResponse(GenerateChatOAuthUrlResponseDto, 201)
@ApiOperation({
summary: 'Generate chat OAuth URL',
description: `Generate an OAuth URL for chat integrations like Slack and MS Teams.
description: `**Deprecated** — use \`POST /integrations/channel-connections/oauth\` (connect) or \`POST /integrations/channel-endpoints/oauth\` (link_user) instead.
Generate an OAuth URL for chat integrations like Slack and MS Teams.
This URL allows subscribers to authorize the integration, enabling the system to send messages
through their chat workspace. The generated URL expires after 5 minutes.`,
deprecated: true,
})
@SdkMethodName('generateChatOAuthUrl')
@RequirePermissions(PermissionsEnum.INTEGRATION_WRITE)
Expand All @@ -435,6 +448,73 @@ export class IntegrationsController {
userScope: body.userScope,
mode: body.mode,
connectionMode: body.connectionMode,
autoLinkUser: body.autoLinkUser,
})
);

return { url };
}

@Post('/channel-connections/oauth')
@ApiResponse(GenerateChatOAuthUrlResponseDto, 201)
@ApiOperation({
summary: 'Generate OAuth URL for a workspace/tenant connection',
description: `Generate an OAuth URL that creates a workspace or tenant-level channel connection (Slack workspace install or MS Teams admin consent).
The generated URL expires after 5 minutes.`,
})
@SdkMethodName('generateConnectOAuthUrl')
@RequirePermissions(PermissionsEnum.INTEGRATION_WRITE)
@ExternalApiAccessible()
@RequireAuthentication()
async generateConnectOAuthUrl(
@UserSession() user: UserSessionData,
@Body() body: GenerateConnectOauthUrlRequestDto
): Promise<GenerateChatOAuthUrlResponseDto> {
await this.checkFeatureEnabled(user);

const url = await this.generateConnectOauthUrlUsecase.execute(
GenerateConnectOauthUrlCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
subscriberId: body.subscriberId,
integrationIdentifier: body.integrationIdentifier,
connectionIdentifier: body.connectionIdentifier,
context: body.context,
scope: body.scope,
connectionMode: body.connectionMode,
autoLinkUser: body.autoLinkUser,
Comment on lines +475 to +485
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize shared connect requests before generating the OAuth URL.

This endpoint exposes connectionMode, but the MS Teams path downstream drops that field and only sees subscriberId, context, and autoLinkUser. A caller can therefore send connectionMode: 'shared' together with subscriberId and/or autoLinkUser: true, and the request is no longer guaranteed to stay tenant/context-scoped. Please enforce the mode here (or in the connect use case) by omitting/rejecting subscriberId for shared and forcing autoLinkUser to false server-side.

Suggested normalization
+    const connectionMode = body.connectionMode ?? 'subscriber';
+    const subscriberId = connectionMode === 'subscriber' ? body.subscriberId : undefined;
+    const autoLinkUser = connectionMode === 'subscriber' ? (body.autoLinkUser ?? true) : false;
+
     const url = await this.generateConnectOauthUrlUsecase.execute(
       GenerateConnectOauthUrlCommand.create({
         environmentId: user.environmentId,
         organizationId: user.organizationId,
-        subscriberId: body.subscriberId,
+        subscriberId,
         integrationIdentifier: body.integrationIdentifier,
         connectionIdentifier: body.connectionIdentifier,
         context: body.context,
         scope: body.scope,
-        connectionMode: body.connectionMode,
-        autoLinkUser: body.autoLinkUser,
+        connectionMode,
+        autoLinkUser,
       })
     );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const url = await this.generateConnectOauthUrlUsecase.execute(
GenerateConnectOauthUrlCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
subscriberId: body.subscriberId,
integrationIdentifier: body.integrationIdentifier,
connectionIdentifier: body.connectionIdentifier,
context: body.context,
scope: body.scope,
connectionMode: body.connectionMode,
autoLinkUser: body.autoLinkUser,
const connectionMode = body.connectionMode ?? 'subscriber';
const subscriberId = connectionMode === 'subscriber' ? body.subscriberId : undefined;
const autoLinkUser = connectionMode === 'subscriber' ? (body.autoLinkUser ?? true) : false;
const url = await this.generateConnectOauthUrlUsecase.execute(
GenerateConnectOauthUrlCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
subscriberId,
integrationIdentifier: body.integrationIdentifier,
connectionIdentifier: body.connectionIdentifier,
context: body.context,
scope: body.scope,
connectionMode,
autoLinkUser,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/app/integrations/integrations.controller.ts` around lines 475 -
485, Before calling generateConnectOauthUrlUsecase.execute /
GenerateConnectOauthUrlCommand.create in the controller, normalize requests
where connectionMode === 'shared' by removing/ignoring any subscriberId and
forcing autoLinkUser to false: i.e., if body.connectionMode === 'shared' then do
not pass body.subscriberId (omit it from the command) and set autoLinkUser:
false in the payload passed to GenerateConnectOauthUrlCommand.create; implement
this normalization in the integrations.controller.ts just prior to invoking
generateConnectOauthUrlUsecase.execute so downstream code cannot receive a
shared request with subscriber-scoped fields.

})
);

return { url };
}

@Post('/channel-endpoints/oauth')
@ApiResponse(GenerateChatOAuthUrlResponseDto, 201)
@ApiOperation({
summary: 'Generate OAuth URL to link a subscriber user identity',
description: `Generate an OAuth URL that links a specific subscriber to their chat identity (Slack user ID or MS Teams user OID).
The generated URL expires after 5 minutes.`,
})
@SdkMethodName('generateLinkUserOAuthUrl')
@RequirePermissions(PermissionsEnum.INTEGRATION_WRITE)
@ExternalApiAccessible()
@RequireAuthentication()
async generateLinkUserOAuthUrl(
@UserSession() user: UserSessionData,
@Body() body: GenerateLinkUserOauthUrlRequestDto
): Promise<GenerateChatOAuthUrlResponseDto> {
await this.checkFeatureEnabled(user);

const url = await this.generateLinkUserOauthUrlUsecase.execute(
GenerateLinkUserOauthUrlCommand.create({
environmentId: user.environmentId,
organizationId: user.organizationId,
subscriberId: body.subscriberId,
integrationIdentifier: body.integrationIdentifier,
connectionIdentifier: body.connectionIdentifier,
context: body.context,
userScope: body.userScope,
})
);

Expand Down
Loading
Loading