mirror of
https://github.com/bitwarden/server
synced 2025-12-31 15:43:16 +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:
@@ -7,7 +7,8 @@ public enum IntegrationType : int
|
||||
Slack = 3,
|
||||
Webhook = 4,
|
||||
Hec = 5,
|
||||
Datadog = 6
|
||||
Datadog = 6,
|
||||
Teams = 7
|
||||
}
|
||||
|
||||
public static class IntegrationTypeExtensions
|
||||
@@ -24,6 +25,8 @@ public static class IntegrationTypeExtensions
|
||||
return "hec";
|
||||
case IntegrationType.Datadog:
|
||||
return "datadog";
|
||||
case IntegrationType.Teams:
|
||||
return "teams";
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public record TeamsIntegrationConfigurationDetails(string ChannelId, Uri ServiceUrl);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Core.Models.Slack;
|
||||
|
||||
|
||||
41
src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs
Normal file
41
src/Core/AdminConsole/Models/Teams/TeamsApiResponse.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bit.Core.Models.Teams;
|
||||
|
||||
/// <summary>Represents the response returned by the Microsoft OAuth 2.0 token endpoint.
|
||||
/// See <see href="https://learn.microsoft.com/graph/auth-v2-user">Microsoft identity platform and OAuth 2.0
|
||||
/// authorization code flow</see>.</summary>
|
||||
public class TeamsOAuthResponse
|
||||
{
|
||||
/// <summary>The access token issued by Microsoft, used to call the Microsoft Graph API.</summary>
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Represents the response from the <c>/me/joinedTeams</c> Microsoft Graph API call.
|
||||
/// See <see href="https://learn.microsoft.com/graph/api/user-list-joinedteams">List joined teams -
|
||||
/// Microsoft Graph v1.0</see>.</summary>
|
||||
public class JoinedTeamsResponse
|
||||
{
|
||||
/// <summary>The collection of teams that the user has joined.</summary>
|
||||
[JsonPropertyName("value")]
|
||||
public List<TeamInfo> Value { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Represents a Microsoft Teams team returned by the Graph API.
|
||||
/// See <see href="https://learn.microsoft.com/graph/api/resources/team">Team resource type -
|
||||
/// Microsoft Graph v1.0</see>.</summary>
|
||||
public class TeamInfo
|
||||
{
|
||||
/// <summary>The unique identifier of the team.</summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The name of the team.</summary>
|
||||
[JsonPropertyName("displayName")]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The ID of the Microsoft Entra tenant for this team.</summary>
|
||||
[JsonPropertyName("tenantId")]
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Bot.Connector.Authentication;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Models.Teams;
|
||||
|
||||
public class TeamsBotCredentialProvider(string clientId, string clientSecret) : ICredentialProvider
|
||||
{
|
||||
private const string _microsoftBotFrameworkIssuer = AuthenticationConstants.ToBotFromChannelTokenIssuer;
|
||||
|
||||
public Task<bool> IsValidAppIdAsync(string appId)
|
||||
{
|
||||
return Task.FromResult(appId == clientId);
|
||||
}
|
||||
|
||||
public Task<string?> GetAppPasswordAsync(string appId)
|
||||
{
|
||||
return Task.FromResult(appId == clientId ? clientSecret : null);
|
||||
}
|
||||
|
||||
public Task<bool> IsAuthenticationDisabledAsync()
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<bool> ValidateIssuerAsync(string issuer)
|
||||
{
|
||||
return Task.FromResult(issuer == _microsoftBotFrameworkIssuer);
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,6 @@ namespace Bit.Core.Repositories;
|
||||
public interface IOrganizationIntegrationRepository : IRepository<OrganizationIntegration, Guid>
|
||||
{
|
||||
Task<List<OrganizationIntegration>> GetManyByOrganizationAsync(Guid organizationId);
|
||||
|
||||
Task<OrganizationIntegration?> GetByTeamsConfigurationTenantIdTeamId(string tenantId, string teamId);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,59 @@
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
/// <summary>Defines operations for interacting with Slack, including OAuth authentication, channel discovery,
|
||||
/// and sending messages.</summary>
|
||||
public interface ISlackService
|
||||
{
|
||||
/// <remarks>Note: This API is not currently used (yet) by any server code. It is here to provide functionality if
|
||||
/// the UI needs to be able to look up channels for a user.</remarks>
|
||||
/// <summary>Retrieves the ID of a Slack channel by name.
|
||||
/// See <see href="https://api.slack.com/methods/conversations.list">conversations.list API</see>.</summary>
|
||||
/// <param name="token">A valid Slack OAuth access token.</param>
|
||||
/// <param name="channelName">The name of the channel to look up.</param>
|
||||
/// <returns>The channel ID if found; otherwise, an empty string.</returns>
|
||||
Task<string> GetChannelIdAsync(string token, string channelName);
|
||||
|
||||
/// <remarks>Note: This API is not currently used (yet) by any server code. It is here to provide functionality if
|
||||
/// the UI needs to be able to look up channels for a user.</remarks>
|
||||
/// <summary>Retrieves the IDs of multiple Slack channels by name.
|
||||
/// See <see href="https://api.slack.com/methods/conversations.list">conversations.list API</see>.</summary>
|
||||
/// <param name="token">A valid Slack OAuth access token.</param>
|
||||
/// <param name="channelNames">A list of channel names to look up.</param>
|
||||
/// <returns>A list of matching channel IDs. Channels that cannot be found are omitted.</returns>
|
||||
Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames);
|
||||
|
||||
/// <remarks>Note: This API is not currently used (yet) by any server code. It is here to provide functionality if
|
||||
/// the UI needs to be able to look up a user by their email address.</remarks>
|
||||
/// <summary>Retrieves the DM channel ID for a Slack user by email.
|
||||
/// See <see href="https://api.slack.com/methods/users.lookupByEmail">users.lookupByEmail API</see> and
|
||||
/// <see href="https://api.slack.com/methods/conversations.open">conversations.open API</see>.</summary>
|
||||
/// <param name="token">A valid Slack OAuth access token.</param>
|
||||
/// <param name="email">The email address of the user to open a DM with.</param>
|
||||
/// <returns>The DM channel ID if successful; otherwise, an empty string.</returns>
|
||||
Task<string> GetDmChannelByEmailAsync(string token, string email);
|
||||
|
||||
/// <summary>Builds the Slack OAuth 2.0 authorization URL for the app.
|
||||
/// See <see href="https://api.slack.com/authentication/oauth-v2">Slack OAuth v2 documentation</see>.</summary>
|
||||
/// <param name="callbackUrl">The absolute redirect URI that Slack will call after user authorization.
|
||||
/// Must match the URI registered with the app configuration.</param>
|
||||
/// <param name="state">A state token used to correlate the request and callback and prevent CSRF attacks.</param>
|
||||
/// <returns>The full authorization URL to which the user should be redirected to begin the sign-in process.</returns>
|
||||
string GetRedirectUrl(string callbackUrl, string state);
|
||||
|
||||
/// <summary>Exchanges a Slack OAuth code for an access token.
|
||||
/// See <see href="https://api.slack.com/methods/oauth.v2.access">oauth.v2.access API</see>.</summary>
|
||||
/// <param name="code">The authorization code returned by Slack via the callback URL after user authorization.</param>
|
||||
/// <param name="redirectUrl">The redirect URI that was used in the authorization request.</param>
|
||||
/// <returns>A valid Slack access token if successful; otherwise, an empty string.</returns>
|
||||
Task<string> ObtainTokenViaOAuth(string code, string redirectUrl);
|
||||
|
||||
/// <summary>Sends a message to a Slack channel by ID.
|
||||
/// See <see href="https://api.slack.com/methods/chat.postMessage">chat.postMessage API</see>.</summary>
|
||||
/// <remarks>This is used primarily by the <see cref="SlackIntegrationHandler"/> to send events to the
|
||||
/// Slack channel.</remarks>
|
||||
/// <param name="token">A valid Slack OAuth access token.</param>
|
||||
/// <param name="message">The message text to send.</param>
|
||||
/// <param name="channelId">The channel ID to send the message to.</param>
|
||||
/// <returns>A task that completes when the message has been sent.</returns>
|
||||
Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
|
||||
}
|
||||
|
||||
49
src/Core/AdminConsole/Services/ITeamsService.cs
Normal file
49
src/Core/AdminConsole/Services/ITeamsService.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Bit.Core.Models.Teams;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service that provides functionality relating to the Microsoft Teams integration including OAuth,
|
||||
/// team discovery and sending a message to a channel in Teams.
|
||||
/// </summary>
|
||||
public interface ITeamsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate the Microsoft Teams OAuth 2.0 authorization URL used to begin the sign-in flow.
|
||||
/// </summary>
|
||||
/// <param name="callbackUrl">The absolute redirect URI that Microsoft will call after user authorization.
|
||||
/// Must match the URI registered with the app configuration.</param>
|
||||
/// <param name="state">A state token used to correlate the request and callback and prevent CSRF attacks.</param>
|
||||
/// <returns>The full authorization URL to which the user should be redirected to begin the sign-in process.</returns>
|
||||
string GetRedirectUrl(string callbackUrl, string state);
|
||||
|
||||
/// <summary>
|
||||
/// Exchange the OAuth code for a Microsoft Graph API access token.
|
||||
/// </summary>
|
||||
/// <param name="code">The code returned from Microsoft via the OAuth callback Url.</param>
|
||||
/// <param name="redirectUrl">The same redirect URI that was passed to the authorization request.</param>
|
||||
/// <returns>A valid Microsoft Graph access token if the exchange succeeds; otherwise, an empty string.</returns>
|
||||
Task<string> ObtainTokenViaOAuth(string code, string redirectUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Get the Teams to which the authenticated user belongs via Microsoft Graph API.
|
||||
/// </summary>
|
||||
/// <param name="accessToken">A valid Microsoft Graph access token for the user (obtained via OAuth).</param>
|
||||
/// <returns>A read-only list of <see cref="TeamInfo"/> objects representing the user’s joined teams.
|
||||
/// Returns an empty list if the request fails or if the token is invalid.</returns>
|
||||
Task<IReadOnlyList<TeamInfo>> GetJoinedTeamsAsync(string accessToken);
|
||||
|
||||
/// <summary>
|
||||
/// Send a message to a specific channel in Teams.
|
||||
/// </summary>
|
||||
/// <remarks>This is used primarily by the <see cref="TeamsIntegrationHandler"/> to send events to the
|
||||
/// Teams channel.</remarks>
|
||||
/// <param name="serviceUri">The service URI associated with the Microsoft Bot Framework connector for the target
|
||||
/// team. Obtained via the bot framework callback.</param>
|
||||
/// <param name="channelId"> The conversation or channel ID where the message should be delivered. Obtained via
|
||||
/// the bot framework callback.</param>
|
||||
/// <param name="message">The message text to post to the channel.</param>
|
||||
/// <returns>A task that completes when the message has been sent. Errors during message delivery are surfaced
|
||||
/// as exceptions from the underlying connector client.</returns>
|
||||
Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Bit.Core.Models.Teams;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.Services.NoopImplementations;
|
||||
|
||||
public class NoopTeamsService : ITeamsService
|
||||
{
|
||||
public string GetRedirectUrl(string callbackUrl, string state)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
|
||||
{
|
||||
return Task.FromResult(string.Empty);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TeamInfo>> GetJoinedTeamsAsync(string accessToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<TeamInfo>>(Array.Empty<TeamInfo>());
|
||||
}
|
||||
|
||||
public Task SendMessageToChannelAsync(Uri serviceUri, string channelId, string message)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user