1
0
mirror of https://github.com/bitwarden/server synced 2025-12-21 18:53:41 +00:00

Add Microsoft Teams integration (#6410)

* Add Microsoft Teams integration

* Fix method naming error

* Expand and clean up unit test coverage

* Update with PR feedback

* Add documentation, add In Progress logic/tests for Teams

* Fixed lowercase Slack

* Added docs; Updated PR suggestions;

* Fix broken tests
This commit is contained in:
Brant DeBow
2025-10-10 10:39:31 -04:00
committed by GitHub
parent 3272586e31
commit a565fd9ee4
41 changed files with 1839 additions and 99 deletions

View File

@@ -203,31 +203,17 @@ Currently, there are integrations / handlers for Slack, webhooks, and HTTP Event
- The top-level object that enables a specific integration for the organization.
- Includes any properties that apply to the entire integration across all events.
- For Slack, it consists of the token: `{ "Token": "xoxb-token-from-slack" }`.
- For webhooks, it is optional. Webhooks can either be configured at this level or the configuration level,
but the configuration level takes precedence. However, even though it is optional, an organization must
have a webhook `OrganizationIntegration` (even will a `null` `Configuration`) to enable configuration
via `OrganizationIntegrationConfiguration`.
- For HEC, it consists of the scheme, token, and URI:
```json
{
"Scheme": "Bearer",
"Token": "Auth-token-from-HEC-service",
"Uri": "https://example.com/api"
}
```
- For example, Slack stores the token in the `Configuration` which applies to every event, but stores the
channel id in the `Configuration` of the `OrganizationIntegrationConfiguration`. The token applies to the entire Slack
integration, but the channel could be configured differently depending on event type.
- See the table below for more examples / details on what is stored at which level.
### `OrganizationIntegrationConfiguration`
- This contains the configurations specific to each `EventType` for the integration.
- `Configuration` contains the event-specific configuration.
- For Slack, this would contain what channel to send the message to: `{ "channelId": "C123456" }`
- For webhooks, this is the URL the request should be sent to: `{ "url": "https://api.example.com" }`
- Optionally this also can include a `Scheme` and `Token` if this webhook needs Authentication.
- As stated above, all of this information can be specified here or at the `OrganizationIntegration`
level, but any properties declared here will take precedence over the ones above.
- For HEC, this must be null. HEC is configured only at the `OrganizationIntegration` level.
- Any properties at this level override the `Configuration` form the `OrganizationIntegration`.
- See the table below for examples of specific integrations.
- `Template` contains a template string that is expected to be filled in with the contents of the actual event.
- The tokens in the string are wrapped in `#` characters. For instance, the UserId would be `#UserId#`.
- The `IntegrationTemplateProcessor` does the actual work of replacing these tokens with introspected values from
@@ -245,6 +231,23 @@ Currently, there are integrations / handlers for Slack, webhooks, and HTTP Event
- An array of `OrganizationIntegrationConfigurationDetails` is what the `EventIntegrationHandler` fetches from
the database to determine what to publish at the integration level.
### Existing integrations and the configurations at each level
The following table illustrates how each integration is configured and what exactly is stored in the `Configuration`
property at each level (`OrganizationIntegration` or `OrganizationIntegrationConfiguration`). Under
`OrganizationIntegration` the valid `OrganizationIntegrationStatus` are in bold, with an example of what would be
stored at each status.
| **Integration** | **OrganizationIntegration** | **OrganizationIntegrationConfiguration** |
|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|
| CloudBillingSync | **Not Applicable** (not yet used) | **Not Applicable** (not yet used) |
| Scim | **Not Applicable** (not yet used) | **Not Applicable** (not yet used) |
| Slack | **Initiated**: `null`<br/>**Completed**:<br/>`{ "Token": "xoxb-token-from-slack" }` | `{ "channelId": "C123456" }` |
| Webhook | `null` or `{ "Scheme": "Bearer", "Token": "AUTH-TOKEN", "Uri": "https://example.com" }` | `null` or `{ "Scheme": "Bearer", "Token":"AUTH-TOKEN", "Uri": "https://example.com" }`<br/><br/>Whatever is defined at this level takes precedence |
| Hec | `{ "Scheme": "Bearer", "Token": "AUTH-TOKEN", "Uri": "https://example.com" }` | Always `null` |
| Datadog | `{ "ApiKey": "TheKey12345", "Uri": "https://api.us5.datadoghq.com/api/v1/events"}` | Always `null` |
| Teams | **Initiated**: `null`<br/>**In Progress**: <br/> `{ "TenantID": "tenant", "Teams": ["Id": "team", DisplayName: "MyTeam"]}`<br/>**Completed**: <br/>`{ "TenantID": "tenant", "Teams": ["Id": "team", DisplayName: "MyTeam"], "ServiceUrl":"https://example.com", ChannelId: "channel-1234"}` | Always `null` |
## Filtering
In addition to the ability to configure integrations mentioned above, organization admins can
@@ -349,10 +352,20 @@ and event type.
- This will be the deserialized version of the `MergedConfiguration` in
`OrganizationIntegrationConfigurationDetails`.
A new row with the new integration should be added to this doc in the table above [Existing integrations
and the configurations at each level](#existing-integrations-and-the-configurations-at-each-level).
## Request Models
1. Add a new case to the switch method in `OrganizationIntegrationRequestModel.Validate`.
- Additionally, add tests in `OrganizationIntegrationRequestModelTests`
2. Add a new case to the switch method in `OrganizationIntegrationConfigurationRequestModel.IsValidForType`.
- Additionally, add / update tests in `OrganizationIntegrationConfigurationRequestModelTests`
## Response Model
1. Add a new case to the switch method in `OrganizationIntegrationResponseModel.Status`.
- Additionally, add / update tests in `OrganizationIntegrationResponseModelTests`
## Integration Handler

View File

@@ -90,6 +90,12 @@ public class SlackService(
public async Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
{
if (string.IsNullOrEmpty(code) || string.IsNullOrWhiteSpace(redirectUrl))
{
logger.LogError("Error obtaining token via OAuth: Code and/or RedirectUrl were empty");
return string.Empty;
}
var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access",
new FormUrlEncodedContent(new[]
{

View File

@@ -0,0 +1,41 @@
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Microsoft.Rest;
namespace Bit.Core.Services;
public class TeamsIntegrationHandler(
ITeamsService teamsService)
: IntegrationHandlerBase<TeamsIntegrationConfigurationDetails>
{
public override async Task<IntegrationHandlerResult> HandleAsync(
IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
{
try
{
await teamsService.SendMessageToChannelAsync(
serviceUri: message.Configuration.ServiceUrl,
message: message.RenderedTemplate,
channelId: message.Configuration.ChannelId
);
return new IntegrationHandlerResult(success: true, message: message);
}
catch (HttpOperationException ex)
{
var result = new IntegrationHandlerResult(success: false, message: message);
var statusCode = (int)ex.Response.StatusCode;
result.Retryable = statusCode is 429 or >= 500 and < 600;
result.FailureReason = ex.Message;
return result;
}
catch (Exception ex)
{
var result = new IntegrationHandlerResult(success: false, message: message);
result.Retryable = false;
result.FailureReason = ex.Message;
return result;
}
}
}

View File

@@ -0,0 +1,182 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Web;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Models.Teams;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Teams;
using Microsoft.Bot.Connector;
using Microsoft.Bot.Connector.Authentication;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Logging;
using TeamInfo = Bit.Core.Models.Teams.TeamInfo;
namespace Bit.Core.Services;
public class TeamsService(
IHttpClientFactory httpClientFactory,
IOrganizationIntegrationRepository integrationRepository,
GlobalSettings globalSettings,
ILogger<TeamsService> logger) : ActivityHandler, ITeamsService
{
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
private readonly string _clientId = globalSettings.Teams.ClientId;
private readonly string _clientSecret = globalSettings.Teams.ClientSecret;
private readonly string _scopes = globalSettings.Teams.Scopes;
private readonly string _graphBaseUrl = globalSettings.Teams.GraphBaseUrl;
private readonly string _loginBaseUrl = globalSettings.Teams.LoginBaseUrl;
public const string HttpClientName = "TeamsServiceHttpClient";
public string GetRedirectUrl(string redirectUrl, string state)
{
var query = HttpUtility.ParseQueryString(string.Empty);
query["client_id"] = _clientId;
query["response_type"] = "code";
query["redirect_uri"] = redirectUrl;
query["response_mode"] = "query";
query["scope"] = string.Join(" ", _scopes);
query["state"] = state;
return $"{_loginBaseUrl}/common/oauth2/v2.0/authorize?{query}";
}
public async Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
{
if (string.IsNullOrEmpty(code) || string.IsNullOrWhiteSpace(redirectUrl))
{
logger.LogError("Error obtaining token via OAuth: Code and/or RedirectUrl were empty");
return string.Empty;
}
var request = new HttpRequestMessage(HttpMethod.Post,
$"{_loginBaseUrl}/common/oauth2/v2.0/token");
request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", _clientId },
{ "client_secret", _clientSecret },
{ "code", code },
{ "redirect_uri", redirectUrl },
{ "grant_type", "authorization_code" }
});
using var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorText = await response.Content.ReadAsStringAsync();
logger.LogError("Teams OAuth token exchange failed: {errorText}", errorText);
return string.Empty;
}
TeamsOAuthResponse? result;
try
{
result = await response.Content.ReadFromJsonAsync<TeamsOAuthResponse>();
}
catch
{
result = null;
}
if (result is null)
{
logger.LogError("Error obtaining token via OAuth: Unknown error");
return string.Empty;
}
return result.AccessToken;
}
public async Task<IReadOnlyList<TeamInfo>> GetJoinedTeamsAsync(string accessToken)
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"{_graphBaseUrl}/me/joinedTeams");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorText = await response.Content.ReadAsStringAsync();
logger.LogError("Get Teams request failed: {errorText}", errorText);
return new List<TeamInfo>();
}
var result = await response.Content.ReadFromJsonAsync<JoinedTeamsResponse>();
return result?.Value ?? [];
}
public async Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message)
{
var credentials = new MicrosoftAppCredentials(_clientId, _clientSecret);
using var connectorClient = new ConnectorClient(serviceUri, credentials);
var activity = new Activity
{
Type = ActivityTypes.Message,
Text = message
};
await connectorClient.Conversations.SendToConversationAsync(channelId, activity);
}
protected override async Task OnInstallationUpdateAddAsync(ITurnContext<IInstallationUpdateActivity> turnContext,
CancellationToken cancellationToken)
{
var conversationId = turnContext.Activity.Conversation.Id;
var serviceUrl = turnContext.Activity.ServiceUrl;
var teamId = turnContext.Activity.TeamsGetTeamInfo().AadGroupId;
var tenantId = turnContext.Activity.Conversation.TenantId;
if (!string.IsNullOrWhiteSpace(conversationId) &&
!string.IsNullOrWhiteSpace(serviceUrl) &&
Uri.TryCreate(serviceUrl, UriKind.Absolute, out var parsedUri) &&
!string.IsNullOrWhiteSpace(teamId) &&
!string.IsNullOrWhiteSpace(tenantId))
{
await HandleIncomingAppInstallAsync(
conversationId: conversationId,
serviceUrl: parsedUri,
teamId: teamId,
tenantId: tenantId
);
}
await base.OnInstallationUpdateAddAsync(turnContext, cancellationToken);
}
internal async Task HandleIncomingAppInstallAsync(
string conversationId,
Uri serviceUrl,
string teamId,
string tenantId)
{
var integration = await integrationRepository.GetByTeamsConfigurationTenantIdTeamId(
tenantId: tenantId,
teamId: teamId);
if (integration?.Configuration is null)
{
return;
}
var teamsConfig = JsonSerializer.Deserialize<TeamsIntegration>(integration.Configuration);
if (teamsConfig is null || teamsConfig.IsCompleted)
{
return;
}
integration.Configuration = JsonSerializer.Serialize(teamsConfig with
{
ChannelId = conversationId,
ServiceUrl = serviceUrl
});
await integrationRepository.UpsertAsync(integration);
}
}