Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
512278b
Add Microsoft Teams integration
brant-livefront Oct 3, 2025
cdca944
Fix method naming error
brant-livefront Oct 3, 2025
886fe32
Merge branch 'main' into brant/microsoft-teams-integration
brant-livefront Oct 6, 2025
85a2b7c
Expand and clean up unit test coverage
brant-livefront Oct 6, 2025
2589ec4
Merge branch 'brant/microsoft-teams-integration' of github.com:bitwar…
brant-livefront Oct 6, 2025
f42fd7b
Merge branch 'main' into brant/microsoft-teams-integration
brant-livefront Oct 6, 2025
59445c0
Merge branch 'main' into brant/microsoft-teams-integration
brant-livefront Oct 7, 2025
752d1d5
Update with PR feedback
brant-livefront Oct 7, 2025
5bfcde8
Merge branch 'main' into brant/microsoft-teams-integration
brant-livefront Oct 7, 2025
b5376d5
Add documentation, add In Progress logic/tests for Teams
brant-livefront Oct 8, 2025
b4024a4
Merge branch 'main' into brant/microsoft-teams-integration
brant-livefront Oct 8, 2025
2ce25ed
Fixed lowercase Slack
brant-livefront Oct 8, 2025
ca9ec78
Merge branch 'main' into brant/microsoft-teams-integration
brant-livefront Oct 8, 2025
00bae24
Merge branch 'main' into brant/microsoft-teams-integration
brant-livefront Oct 9, 2025
ca3f6d0
Added docs; Updated PR suggestions;
brant-livefront Oct 9, 2025
1580da7
Merge branch 'main' into brant/microsoft-teams-integration
brant-livefront Oct 9, 2025
a812d90
Fix broken tests
brant-livefront Oct 9, 2025
81fd273
Merge branch 'main' into brant/microsoft-teams-integration
brant-livefront Oct 10, 2025
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
33 changes: 17 additions & 16 deletions dev/servicebusemulator_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,6 @@
"Namespaces": [
{
"Name": "sbemulatorns",
"Queues": [
{
"Name": "queue.1",
"Properties": {
"DeadLetteringOnMessageExpiration": false,
"DefaultMessageTimeToLive": "PT1H",
"DuplicateDetectionHistoryTimeWindow": "PT20S",
"ForwardDeadLetteredMessagesTo": "",
"ForwardTo": "",
"LockDuration": "PT1M",
"MaxDeliveryCount": 3,
"RequiresDuplicateDetection": false,
"RequiresSession": false
}
}
],
"Topics": [
{
"Name": "event-logging",
Expand All @@ -37,6 +21,9 @@
},
{
"Name": "events-datadog-subscription"
},
{
"Name": "events-teams-subscription"
}
]
},
Expand Down Expand Up @@ -98,6 +85,20 @@
}
}
]
},
{
"Name": "integration-teams-subscription",
"Rules": [
{
"Name": "teams-integration-filter",
"Properties": {
"FilterType": "Correlation",
"CorrelationFilter": {
"Label": "teams"
}
}
}
]
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public async Task<IActionResult> RedirectAsync(Guid organizationId)
}

string? callbackUrl = Url.RouteUrl(
routeName: nameof(CreateAsync),
routeName: "SlackIntegration_Create",
values: null,
protocol: currentContext.HttpContext.Request.Scheme,
host: currentContext.HttpContext.Request.Host.ToUriComponent()
Expand Down Expand Up @@ -76,7 +76,7 @@ public async Task<IActionResult> RedirectAsync(Guid organizationId)
return Redirect(redirectUrl);
}

[HttpGet("integrations/slack/create", Name = nameof(CreateAsync))]
[HttpGet("integrations/slack/create", Name = "SlackIntegration_Create")]
[AllowAnonymous]
public async Task<IActionResult> CreateAsync([FromQuery] string code, [FromQuery] string state)
{
Expand All @@ -103,7 +103,7 @@ public async Task<IActionResult> CreateAsync([FromQuery] string code, [FromQuery

// Fetch token from Slack and store to DB
string? callbackUrl = Url.RouteUrl(
routeName: nameof(CreateAsync),
routeName: "SlackIntegration_Create",
values: null,
protocol: currentContext.HttpContext.Request.Scheme,
host: currentContext.HttpContext.Request.Host.ToUriComponent()
Expand Down
147 changes: 147 additions & 0 deletions src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Integration.AspNet.Core;

namespace Bit.Api.AdminConsole.Controllers;

[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations")]
[Authorize("Application")]
public class TeamsIntegrationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository,
IBot bot,
IBotFrameworkHttpAdapter adapter,
ITeamsService teamsService,
TimeProvider timeProvider) : Controller
{
[HttpGet("{organizationId:guid}/integrations/teams/redirect")]
public async Task<IActionResult> RedirectAsync(Guid organizationId)
{
if (!await currentContext.OrganizationOwner(organizationId))
{
throw new NotFoundException();
}

var callbackUrl = Url.RouteUrl(
routeName: "TeamsIntegration_Create",
values: null,
protocol: currentContext.HttpContext.Request.Scheme,
host: currentContext.HttpContext.Request.Host.ToUriComponent()
);
if (string.IsNullOrEmpty(callbackUrl))
{
throw new BadRequestException("Unable to build callback Url");
}

var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId);
var integration = integrations.FirstOrDefault(i => i.Type == IntegrationType.Teams);

if (integration is null)
{
// No teams integration exists, create Initiated version
integration = await integrationRepository.CreateAsync(new OrganizationIntegration
{
OrganizationId = organizationId,
Type = IntegrationType.Teams,
Configuration = null,
});
}
else if (integration.Configuration is not null)
{
// A Completed (fully configured) Teams integration already exists, throw to prevent overriding
throw new BadRequestException("There already exists a Teams integration for this organization");

} // An Initiated teams integration exits, re-use it and kick off a new OAuth flow

var state = IntegrationOAuthState.FromIntegration(integration, timeProvider);
var redirectUrl = teamsService.GetRedirectUrl(
callbackUrl: callbackUrl,
state: state.ToString()
);

if (string.IsNullOrEmpty(redirectUrl))
{
throw new NotFoundException();
}

return Redirect(redirectUrl);
}

[HttpGet("integrations/teams/create", Name = "TeamsIntegration_Create")]
[AllowAnonymous]
public async Task<IActionResult> CreateAsync([FromQuery] string code, [FromQuery] string state)
{
var oAuthState = IntegrationOAuthState.FromString(state: state, timeProvider: timeProvider);
if (oAuthState is null)
{
throw new NotFoundException();
}

// Fetch existing Initiated record
var integration = await integrationRepository.GetByIdAsync(oAuthState.IntegrationId);
if (integration is null ||
integration.Type != IntegrationType.Teams ||
integration.Configuration is not null)
{
throw new NotFoundException();
}

// Verify Organization matches hash
if (!oAuthState.ValidateOrg(integration.OrganizationId))
{
throw new NotFoundException();
}

var callbackUrl = Url.RouteUrl(
routeName: "TeamsIntegration_Create",
values: null,
protocol: currentContext.HttpContext.Request.Scheme,
host: currentContext.HttpContext.Request.Host.ToUriComponent()
);
if (string.IsNullOrEmpty(callbackUrl))
{
throw new BadRequestException("Unable to build callback Url");
}

var token = await teamsService.ObtainTokenViaOAuth(code, callbackUrl);
if (string.IsNullOrEmpty(token))
{
throw new BadRequestException("Invalid response from Teams.");
}

var teams = await teamsService.GetJoinedTeamsAsync(token);

if (!teams.Any())
{
throw new BadRequestException("No teams were found.");
}

var teamsIntegration = new TeamsIntegration(TenantId: teams[0].TenantId, Teams: teams);
integration.Configuration = JsonSerializer.Serialize(teamsIntegration);
await integrationRepository.UpsertAsync(integration);

var location = $"/organizations/{integration.OrganizationId}/integrations/{integration.Id}";
return Created(location, new OrganizationIntegrationResponseModel(integration));
}

[Route("integrations/teams/incoming")]
[AllowAnonymous]
[HttpPost]
public async Task IncomingPostAsync()
{
await adapter.ProcessAsync(Request, Response, bot);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ Configuration is null &&
return !string.IsNullOrWhiteSpace(Template) &&
Configuration is null &&
IsFiltersValid();
case IntegrationType.Teams:
return !string.IsNullOrWhiteSpace(Template) &&
Configuration is null &&
IsFiltersValid();
default:
return false;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public IEnumerable<ValidationResult> Validate(ValidationContext validationContex
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", [nameof(Type)]);
break;
case IntegrationType.Slack:
case IntegrationType.Slack or IntegrationType.Teams:
yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", [nameof(Type)]);
break;
case IntegrationType.Webhook:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
using Bit.Core.Models.Api;

Expand Down Expand Up @@ -35,6 +37,16 @@ public OrganizationIntegrationResponseModel(OrganizationIntegration organization
? OrganizationIntegrationStatus.Initiated
: OrganizationIntegrationStatus.Completed,

// If present and the configuration is null, OAuth has been initiated, and we are
// waiting on the return OAuth call. If Configuration is not null and IsCompleted is true,
// then we've received the app install bot callback, and it's Completed. Otherwise,
// it is In Progress while we await the app install bot callback.
IntegrationType.Teams => string.IsNullOrWhiteSpace(Configuration)
? OrganizationIntegrationStatus.Initiated
: (JsonSerializer.Deserialize<TeamsIntegration>(Configuration)?.IsCompleted ?? false)
? OrganizationIntegrationStatus.Completed
: OrganizationIntegrationStatus.InProgress,

// HEC and Datadog should only be allowed to be created non-null.
// If they are null, they are Invalid
IntegrationType.Hec => string.IsNullOrWhiteSpace(Configuration)
Expand Down
3 changes: 2 additions & 1 deletion src/Api/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,9 @@ public void ConfigureServices(IServiceCollection services)
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
}

// Add SlackService for OAuth API requests - if configured
// Add Slack / Teams Services for OAuth API requests - if configured
services.AddSlackService(globalSettings);
services.AddTeamsService(globalSettings);
}

public void Configure(
Expand Down
5 changes: 4 additions & 1 deletion src/Core/AdminConsole/Enums/IntegrationType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ public enum IntegrationType : int
Slack = 3,
Webhook = 4,
Hec = 5,
Datadog = 6
Datadog = 6,
Teams = 7
}

public static class IntegrationTypeExtensions
Expand All @@ -24,6 +25,8 @@ public static string ToRoutingKey(this IntegrationType type)
return "hec";
case IntegrationType.Datadog:
return "datadog";
case IntegrationType.Teams:
return "teams";
default:
throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Bit.Core.Models.Teams;

namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;

public record TeamsIntegration(
string TenantId,
IReadOnlyList<TeamInfo> Teams,
string? ChannelId = null,
Uri? ServiceUrl = null)
{
public bool IsCompleted => !string.IsNullOrEmpty(ChannelId) && ServiceUrl is not null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;

public record TeamsIntegrationConfigurationDetails(string ChannelId, Uri ServiceUrl);
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Bit.Core.Enums;
using Bit.Core.Settings;

namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;

public class TeamsListenerConfiguration(GlobalSettings globalSettings) :
ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration
{
public IntegrationType IntegrationType
{
get => IntegrationType.Teams;
}

public string EventQueueName
{
get => _globalSettings.EventLogging.RabbitMq.TeamsEventsQueueName;
}

public string IntegrationQueueName
{
get => _globalSettings.EventLogging.RabbitMq.TeamsIntegrationQueueName;
}

public string IntegrationRetryQueueName
{
get => _globalSettings.EventLogging.RabbitMq.TeamsIntegrationRetryQueueName;
}

public string EventSubscriptionName
{
get => _globalSettings.EventLogging.AzureServiceBus.TeamsEventSubscriptionName;
}

public string IntegrationSubscriptionName
{
get => _globalSettings.EventLogging.AzureServiceBus.TeamsIntegrationSubscriptionName;
}
}
4 changes: 1 addition & 3 deletions src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
#nullable enable

using System.Text.Json.Serialization;
using System.Text.Json.Serialization;

namespace Bit.Core.Models.Slack;

Expand Down
Loading
Loading