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:
@@ -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
|
||||
|
||||
|
||||
@@ -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[]
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user