diff --git a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs index 6e3751c6f6..c8ff4f9f7c 100644 --- a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Text.Json; +using System.Text.Json; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Core; using Bit.Core.AdminConsole.Entities; @@ -18,25 +15,58 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.AdminConsole.Controllers; [RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] -[Route("organizations/{organizationId:guid}/integrations/slack")] +[Route("organizations")] [Authorize("Application")] public class SlackIntegrationController( ICurrentContext currentContext, IOrganizationIntegrationRepository integrationRepository, - ISlackService slackService) : Controller + ISlackService slackService, + TimeProvider timeProvider) : Controller { - [HttpGet("redirect")] + [HttpGet("{organizationId:guid}/integrations/slack/redirect")] public async Task RedirectAsync(Guid organizationId) { if (!await currentContext.OrganizationOwner(organizationId)) { throw new NotFoundException(); } - string callbackUrl = Url.RouteUrl( - nameof(CreateAsync), - new { organizationId }, - currentContext.HttpContext.Request.Scheme); - var redirectUrl = slackService.GetRedirectUrl(callbackUrl); + + string? callbackUrl = Url.RouteUrl( + routeName: nameof(CreateAsync), + 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.Slack); + + if (integration is null) + { + // No slack integration exists, create Initiated version + integration = await integrationRepository.CreateAsync(new OrganizationIntegration + { + OrganizationId = organizationId, + Type = IntegrationType.Slack, + Configuration = null, + }); + } + else if (integration.Configuration is not null) + { + // A Completed (fully configured) Slack integration already exists, throw to prevent overriding + throw new BadRequestException("There already exists a Slack integration for this organization"); + + } // An Initiated slack integration exits, re-use it and kick off a new OAuth flow + + var state = IntegrationOAuthState.FromIntegration(integration, timeProvider); + var redirectUrl = slackService.GetRedirectUrl( + callbackUrl: callbackUrl, + state: state.ToString() + ); if (string.IsNullOrEmpty(redirectUrl)) { @@ -46,23 +76,42 @@ public class SlackIntegrationController( return Redirect(redirectUrl); } - [HttpGet("create", Name = nameof(CreateAsync))] - public async Task CreateAsync(Guid organizationId, [FromQuery] string code) + [HttpGet("integrations/slack/create", Name = nameof(CreateAsync))] + [AllowAnonymous] + public async Task CreateAsync([FromQuery] string code, [FromQuery] string state) { - if (!await currentContext.OrganizationOwner(organizationId)) + var oAuthState = IntegrationOAuthState.FromString(state: state, timeProvider: timeProvider); + if (oAuthState is null) { throw new NotFoundException(); } - if (string.IsNullOrEmpty(code)) + // Fetch existing Initiated record + var integration = await integrationRepository.GetByIdAsync(oAuthState.IntegrationId); + if (integration is null || + integration.Type != IntegrationType.Slack || + integration.Configuration is not null) { - throw new BadRequestException("Missing code from Slack."); + throw new NotFoundException(); } - string callbackUrl = Url.RouteUrl( - nameof(CreateAsync), - new { organizationId }, - currentContext.HttpContext.Request.Scheme); + // Verify Organization matches hash + if (!oAuthState.ValidateOrg(integration.OrganizationId)) + { + throw new NotFoundException(); + } + + // Fetch token from Slack and store to DB + string? callbackUrl = Url.RouteUrl( + routeName: nameof(CreateAsync), + 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 slackService.ObtainTokenViaOAuth(code, callbackUrl); if (string.IsNullOrEmpty(token)) @@ -70,14 +119,10 @@ public class SlackIntegrationController( throw new BadRequestException("Invalid response from Slack."); } - var integration = await integrationRepository.CreateAsync(new OrganizationIntegration - { - OrganizationId = organizationId, - Type = IntegrationType.Slack, - Configuration = JsonSerializer.Serialize(new SlackIntegration(token)), - }); - var location = $"/organizations/{organizationId}/integrations/{integration.Id}"; + integration.Configuration = JsonSerializer.Serialize(new SlackIntegration(token)); + await integrationRepository.UpsertAsync(integration); + var location = $"/organizations/{integration.OrganizationId}/integrations/{integration.Id}"; return Created(location, new OrganizationIntegrationResponseModel(integration)); } } diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs index f062ff46a2..5368f78e39 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModel.cs @@ -2,8 +2,6 @@ using Bit.Core.Enums; using Bit.Core.Models.Api; -#nullable enable - namespace Bit.Api.AdminConsole.Models.Response.Organizations; public class OrganizationIntegrationResponseModel : ResponseModel @@ -21,4 +19,29 @@ public class OrganizationIntegrationResponseModel : ResponseModel public Guid Id { get; set; } public IntegrationType Type { get; set; } public string? Configuration { get; set; } + + public OrganizationIntegrationStatus Status => Type switch + { + // Not yet implemented, shouldn't be present, NotApplicable + IntegrationType.CloudBillingSync => OrganizationIntegrationStatus.NotApplicable, + IntegrationType.Scim => OrganizationIntegrationStatus.NotApplicable, + + // Webhook is allowed to be null. If it's present, it's Completed + IntegrationType.Webhook => OrganizationIntegrationStatus.Completed, + + // If present and the configuration is null, OAuth has been initiated, and we are + // waiting on the return call + IntegrationType.Slack => string.IsNullOrWhiteSpace(Configuration) + ? OrganizationIntegrationStatus.Initiated + : OrganizationIntegrationStatus.Completed, + + // HEC and Datadog should only be allowed to be created non-null. + // If they are null, they are Invalid + IntegrationType.Hec => string.IsNullOrWhiteSpace(Configuration) + ? OrganizationIntegrationStatus.Invalid + : OrganizationIntegrationStatus.Completed, + IntegrationType.Datadog => string.IsNullOrWhiteSpace(Configuration) + ? OrganizationIntegrationStatus.Invalid + : OrganizationIntegrationStatus.Completed, + }; } diff --git a/src/Core/AdminConsole/Enums/OrganizationIntegrationStatus.cs b/src/Core/AdminConsole/Enums/OrganizationIntegrationStatus.cs new file mode 100644 index 0000000000..78a7bc6d63 --- /dev/null +++ b/src/Core/AdminConsole/Enums/OrganizationIntegrationStatus.cs @@ -0,0 +1,10 @@ +namespace Bit.Api.AdminConsole.Models.Response.Organizations; + +public enum OrganizationIntegrationStatus : int +{ + NotApplicable, + Invalid, + Initiated, + InProgress, + Completed +} diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationOAuthState.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationOAuthState.cs new file mode 100644 index 0000000000..3b29bbebb4 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationOAuthState.cs @@ -0,0 +1,71 @@ +using System.Security.Cryptography; +using System.Text; +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class IntegrationOAuthState +{ + private const int _orgHashLength = 12; + private static readonly TimeSpan _maxAge = TimeSpan.FromMinutes(20); + + public Guid IntegrationId { get; } + private DateTimeOffset Issued { get; } + private string OrganizationIdHash { get; } + + private IntegrationOAuthState(Guid integrationId, string organizationIdHash, DateTimeOffset issued) + { + IntegrationId = integrationId; + OrganizationIdHash = organizationIdHash; + Issued = issued; + } + + public static IntegrationOAuthState FromIntegration(OrganizationIntegration integration, TimeProvider timeProvider) + { + var integrationId = integration.Id; + var issuedUtc = timeProvider.GetUtcNow(); + var organizationIdHash = ComputeOrgHash(integration.OrganizationId, issuedUtc.ToUnixTimeSeconds()); + + return new IntegrationOAuthState(integrationId, organizationIdHash, issuedUtc); + } + + public static IntegrationOAuthState? FromString(string state, TimeProvider timeProvider) + { + if (string.IsNullOrWhiteSpace(state)) return null; + + var parts = state.Split('.'); + if (parts.Length != 3) return null; + + // Verify timestamp + if (!long.TryParse(parts[2], out var unixSeconds)) return null; + + var issuedUtc = DateTimeOffset.FromUnixTimeSeconds(unixSeconds); + var now = timeProvider.GetUtcNow(); + var age = now - issuedUtc; + + if (age > _maxAge) return null; + + // Parse integration id and store org + if (!Guid.TryParse(parts[0], out var integrationId)) return null; + var organizationIdHash = parts[1]; + + return new IntegrationOAuthState(integrationId, organizationIdHash, issuedUtc); + } + + public bool ValidateOrg(Guid orgId) + { + var expected = ComputeOrgHash(orgId, Issued.ToUnixTimeSeconds()); + return expected == OrganizationIdHash; + } + + public override string ToString() + { + return $"{IntegrationId}.{OrganizationIdHash}.{Issued.ToUnixTimeSeconds()}"; + } + + private static string ComputeOrgHash(Guid orgId, long timestamp) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{orgId:N}:{timestamp}")); + return Convert.ToHexString(bytes)[.._orgHashLength]; + } +} diff --git a/src/Core/AdminConsole/Services/ISlackService.cs b/src/Core/AdminConsole/Services/ISlackService.cs index 6c6a846f0d..ff1e03f051 100644 --- a/src/Core/AdminConsole/Services/ISlackService.cs +++ b/src/Core/AdminConsole/Services/ISlackService.cs @@ -5,7 +5,7 @@ public interface ISlackService Task GetChannelIdAsync(string token, string channelName); Task> GetChannelIdsAsync(string token, List channelNames); Task GetDmChannelByEmailAsync(string token, string email); - string GetRedirectUrl(string redirectUrl); + string GetRedirectUrl(string callbackUrl, string state); Task ObtainTokenViaOAuth(string code, string redirectUrl); Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId); } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs index f17185c4d3..4fb74f1f44 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs @@ -19,6 +19,7 @@ public class SlackService( private readonly string _slackApiBaseUrl = globalSettings.Slack.ApiBaseUrl; public const string HttpClientName = "SlackServiceHttpClient"; + private const string _slackOAuthBaseUri = "https://slack.com/oauth/v2/authorize"; public async Task GetChannelIdAsync(string token, string channelName) { @@ -73,9 +74,18 @@ public class SlackService( return await OpenDmChannel(token, userId); } - public string GetRedirectUrl(string redirectUrl) + public string GetRedirectUrl(string callbackUrl, string state) { - return $"https://slack.com/oauth/v2/authorize?client_id={_clientId}&scope={_scopes}&redirect_uri={redirectUrl}"; + var builder = new UriBuilder(_slackOAuthBaseUri); + var query = HttpUtility.ParseQueryString(builder.Query); + + query["client_id"] = _clientId; + query["scope"] = _scopes; + query["redirect_uri"] = callbackUrl; + query["state"] = state; + + builder.Query = query.ToString(); + return builder.ToString(); } public async Task ObtainTokenViaOAuth(string code, string redirectUrl) diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs index c34c073e87..d6c8d08c4c 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs @@ -19,7 +19,7 @@ public class NoopSlackService : ISlackService return Task.FromResult(string.Empty); } - public string GetRedirectUrl(string redirectUrl) + public string GetRedirectUrl(string callbackUrl, string state) { return string.Empty; } diff --git a/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs index 9bbc8a77c0..376fb01493 100644 --- a/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs @@ -1,12 +1,18 @@ -using Bit.Api.AdminConsole.Controllers; +#nullable enable + +using Bit.Api.AdminConsole.Controllers; 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.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; @@ -16,98 +22,312 @@ namespace Bit.Api.Test.AdminConsole.Controllers; [SutProviderCustomize] public class SlackIntegrationControllerTests { + private const string _slackToken = "xoxb-test-token"; + private const string _validSlackCode = "A_test_code"; + [Theory, BitAutoData] - public async Task CreateAsync_AllParamsProvided_Succeeds(SutProvider sutProvider, Guid organizationId) + public async Task CreateAsync_AllParamsProvided_Succeeds( + SutProvider sutProvider, + OrganizationIntegration integration) { - var token = "xoxb-test-token"; + integration.Type = IntegrationType.Slack; + integration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); sutProvider.GetDependency() - .ObtainTokenViaOAuth(Arg.Any(), Arg.Any()) - .Returns(token); + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) + .Returns(_slackToken); sutProvider.GetDependency() - .CreateAsync(Arg.Any()) - .Returns(callInfo => callInfo.Arg()); - var requestAction = await sutProvider.Sut.CreateAsync(organizationId, "A_test_code"); + .GetByIdAsync(integration.Id) + .Returns(integration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + var requestAction = await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString()); await sutProvider.GetDependency().Received(1) - .CreateAsync(Arg.Any()); + .UpsertAsync(Arg.Any()); Assert.IsType(requestAction); } [Theory, BitAutoData] - public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest(SutProvider sutProvider, Guid organizationId) + public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration) { + integration.Type = IntegrationType.Slack; + integration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(organizationId, string.Empty)); + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.CreateAsync(string.Empty, state.ToString())); } [Theory, BitAutoData] - public async Task CreateAsync_SlackServiceReturnsEmpty_ThrowsBadRequest(SutProvider sutProvider, Guid organizationId) + public async Task CreateAsync_SlackServiceReturnsEmpty_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration) { + integration.Type = IntegrationType.Slack; + integration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(true); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); sutProvider.GetDependency() - .ObtainTokenViaOAuth(Arg.Any(), Arg.Any()) + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) .Returns(string.Empty); + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(organizationId, "A_test_code")); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString())); } [Theory, BitAutoData] - public async Task CreateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) + public async Task CreateAsync_StateEmpty_ThrowsNotFound( + SutProvider sutProvider) { - var token = "xoxb-test-token"; sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency() - .OrganizationOwner(organizationId) - .Returns(false); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); sutProvider.GetDependency() - .ObtainTokenViaOAuth(Arg.Any(), Arg.Any()) - .Returns(token); + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) + .Returns(_slackToken); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(organizationId, "A_test_code")); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, String.Empty)); } [Theory, BitAutoData] - public async Task RedirectAsync_Success(SutProvider sutProvider, Guid organizationId) + public async Task CreateAsync_StateExpired_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) { - var expectedUrl = $"https://localhost/{organizationId}"; + var timeProvider = new FakeTimeProvider(new DateTime(2024, 4, 3, 2, 1, 0, DateTimeKind.Utc)); + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) + .Returns(_slackToken); + var state = IntegrationOAuthState.FromIntegration(integration, timeProvider); + timeProvider.Advance(TimeSpan.FromMinutes(30)); + + sutProvider.SetDependency(timeProvider); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasNonexistentIntegration_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) + .Returns(_slackToken); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasWrongOgranizationHash_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration, + OrganizationIntegration wrongOrgIntegration) + { + wrongOrgIntegration.Id = integration.Id; sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency().GetRedirectUrl(Arg.Any()).Returns(expectedUrl); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) + .Returns(_slackToken); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(wrongOrgIntegration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasNonEmptyIntegration_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Slack; + integration.Configuration = "{}"; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) + .Returns(_slackToken); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task CreateAsync_StateHasNonSlackIntegration_ThrowsNotFound( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Hec; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns("https://localhost"); + sutProvider.GetDependency() + .ObtainTokenViaOAuth(_validSlackCode, Arg.Any()) + .Returns(_slackToken); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + await Assert.ThrowsAsync(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString())); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_Success( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Configuration = null; + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns(expectedUrl); + sutProvider.GetDependency() + .OrganizationOwner(integration.OrganizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(integration.OrganizationId) + .Returns([]); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(integration); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(expectedUrl); + + var expectedState = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + var requestAction = await sutProvider.Sut.RedirectAsync(integration.OrganizationId); + + Assert.IsType(requestAction); + await sutProvider.GetDependency().Received(1) + .CreateAsync(Arg.Any()); + sutProvider.GetDependency().Received(1).GetRedirectUrl(Arg.Any(), expectedState.ToString()); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_IntegrationAlreadyExistsWithNullConfig_Success( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) + { + integration.OrganizationId = organizationId; + integration.Configuration = null; + integration.Type = IntegrationType.Slack; + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns(expectedUrl); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); - sutProvider.GetDependency() - .HttpContext.Request.Scheme - .Returns("https"); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([integration]); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(expectedUrl); var requestAction = await sutProvider.Sut.RedirectAsync(organizationId); - var redirectResult = Assert.IsType(requestAction); - Assert.Equal(expectedUrl, redirectResult.Url); + var expectedState = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + Assert.IsType(requestAction); + sutProvider.GetDependency().Received(1).GetRedirectUrl(Arg.Any(), expectedState.ToString()); } [Theory, BitAutoData] - public async Task RedirectAsync_SlackServiceReturnsEmpty_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) + public async Task RedirectAsync_IntegrationAlreadyExistsWithConfig_ThrowsBadRequest( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) { + integration.OrganizationId = organizationId; + integration.Configuration = "{}"; + integration.Type = IntegrationType.Slack; + var expectedUrl = "https://localhost/"; + sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency().GetRedirectUrl(Arg.Any()).Returns(string.Empty); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns(expectedUrl); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([integration]); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(expectedUrl); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); + } + + [Theory, BitAutoData] + public async Task RedirectAsync_SlackServiceReturnsEmpty_ThrowsNotFound( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) + { + integration.OrganizationId = organizationId; + integration.Configuration = null; + var expectedUrl = "https://localhost/"; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync))) + .Returns(expectedUrl); sutProvider.GetDependency() - .HttpContext.Request.Scheme - .Returns("https"); + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([]); + sutProvider.GetDependency() + .CreateAsync(Arg.Any()) + .Returns(integration); + sutProvider.GetDependency().GetRedirectUrl(Arg.Any(), Arg.Any()).Returns(string.Empty); await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); } @@ -116,14 +336,9 @@ public class SlackIntegrationControllerTests public async Task RedirectAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider sutProvider, Guid organizationId) { - sutProvider.Sut.Url = Substitute.For(); - sutProvider.GetDependency().GetRedirectUrl(Arg.Any()).Returns(string.Empty); sutProvider.GetDependency() .OrganizationOwner(organizationId) .Returns(false); - sutProvider.GetDependency() - .HttpContext.Request.Scheme - .Returns("https"); await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); } diff --git a/test/Api.Test/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModelTests.cs b/test/Api.Test/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModelTests.cs new file mode 100644 index 0000000000..babdf3894d --- /dev/null +++ b/test/Api.Test/AdminConsole/Models/Response/Organizations/OrganizationIntegrationResponseModelTests.cs @@ -0,0 +1,117 @@ +#nullable enable + +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Models.Response.Organizations; + +public class OrganizationIntegrationResponseModelTests +{ + [Theory, BitAutoData] + public void Status_CloudBillingSync_AlwaysNotApplicable(OrganizationIntegration oi) + { + oi.Type = IntegrationType.CloudBillingSync; + oi.Configuration = null; + + var model = new OrganizationIntegrationResponseModel(oi); + Assert.Equal(OrganizationIntegrationStatus.NotApplicable, model.Status); + + model.Configuration = "{}"; + Assert.Equal(OrganizationIntegrationStatus.NotApplicable, model.Status); + } + + [Theory, BitAutoData] + public void Status_Scim_AlwaysNotApplicable(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Scim; + oi.Configuration = null; + + var model = new OrganizationIntegrationResponseModel(oi); + Assert.Equal(OrganizationIntegrationStatus.NotApplicable, model.Status); + + model.Configuration = "{}"; + Assert.Equal(OrganizationIntegrationStatus.NotApplicable, model.Status); + } + + [Theory, BitAutoData] + public void Status_Slack_NullConfig_ReturnsInitiated(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Slack; + oi.Configuration = null; + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Initiated, model.Status); + } + + [Theory, BitAutoData] + public void Status_Slack_WithConfig_ReturnsCompleted(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Slack; + oi.Configuration = "{}"; + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status); + } + + [Theory, BitAutoData] + public void Status_Webhook_AlwaysCompleted(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Webhook; + oi.Configuration = null; + + var model = new OrganizationIntegrationResponseModel(oi); + Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status); + + model.Configuration = "{}"; + Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status); + } + + [Theory, BitAutoData] + public void Status_Hec_NullConfig_ReturnsInvalid(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Hec; + oi.Configuration = null; + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Invalid, model.Status); + } + + [Theory, BitAutoData] + public void Status_Hec_WithConfig_ReturnsCompleted(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Hec; + oi.Configuration = "{}"; + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status); + } + + [Theory, BitAutoData] + public void Status_Datadog_NullConfig_ReturnsInvalid(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Datadog; + oi.Configuration = null; + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Invalid, model.Status); + } + + [Theory, BitAutoData] + public void Status_Datadog_WithConfig_ReturnsCompleted(OrganizationIntegration oi) + { + oi.Type = IntegrationType.Datadog; + oi.Configuration = "{}"; + + var model = new OrganizationIntegrationResponseModel(oi); + + Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status); + } +} diff --git a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationOAuthStateTests.cs b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationOAuthStateTests.cs new file mode 100644 index 0000000000..8605a3dcab --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationOAuthStateTests.cs @@ -0,0 +1,91 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Models.Data.EventIntegrations; + +public class IntegrationOAuthStateTests +{ + private readonly FakeTimeProvider _fakeTimeProvider = new( + new DateTime(2014, 3, 2, 1, 0, 0, DateTimeKind.Utc) + ); + + [Theory, BitAutoData] + public void FromIntegration_ToString_RoundTripsCorrectly(OrganizationIntegration integration) + { + var state = IntegrationOAuthState.FromIntegration(integration, _fakeTimeProvider); + var parsed = IntegrationOAuthState.FromString(state.ToString(), _fakeTimeProvider); + + Assert.NotNull(parsed); + Assert.Equal(state.IntegrationId, parsed.IntegrationId); + Assert.True(parsed.ValidateOrg(integration.OrganizationId)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-valid-state")] + public void FromString_InvalidString_ReturnsNull(string state) + { + var parsed = IntegrationOAuthState.FromString(state, _fakeTimeProvider); + + Assert.Null(parsed); + } + + [Fact] + public void FromString_InvalidGuid_ReturnsNull() + { + var badState = $"not-a-guid.ABCD1234.1706313600"; + + var parsed = IntegrationOAuthState.FromString(badState, _fakeTimeProvider); + + Assert.Null(parsed); + } + + [Theory, BitAutoData] + public void FromString_ExpiredState_ReturnsNull(OrganizationIntegration integration) + { + var state = IntegrationOAuthState.FromIntegration(integration, _fakeTimeProvider); + + // Advance time 30 minutes to exceed the 20-minute max age + _fakeTimeProvider.Advance(TimeSpan.FromMinutes(30)); + + var parsed = IntegrationOAuthState.FromString(state.ToString(), _fakeTimeProvider); + + Assert.Null(parsed); + } + + [Theory, BitAutoData] + public void ValidateOrg_WithCorrectOrgId_ReturnsTrue(OrganizationIntegration integration) + { + var state = IntegrationOAuthState.FromIntegration(integration, _fakeTimeProvider); + + Assert.True(state.ValidateOrg(integration.OrganizationId)); + } + + [Theory, BitAutoData] + public void ValidateOrg_WithWrongOrgId_ReturnsFalse(OrganizationIntegration integration) + { + var state = IntegrationOAuthState.FromIntegration(integration, _fakeTimeProvider); + + Assert.False(state.ValidateOrg(Guid.NewGuid())); + } + + [Theory, BitAutoData] + public void ValidateOrg_ModifiedTimestamp_ReturnsFalse(OrganizationIntegration integration) + { + var state = IntegrationOAuthState.FromIntegration(integration, _fakeTimeProvider); + var parts = state.ToString().Split('.'); + + parts[2] = $"{_fakeTimeProvider.GetUtcNow().ToUnixTimeSeconds() - 1}"; + var modifiedState = IntegrationOAuthState.FromString(string.Join(".", parts), _fakeTimeProvider); + + Assert.True(state.ValidateOrg(integration.OrganizationId)); + Assert.NotNull(modifiedState); + Assert.False(modifiedState.ValidateOrg(integration.OrganizationId)); + } +} diff --git a/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs index 92544551e0..2d0ca2433a 100644 --- a/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Text.Json; +using System.Web; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -261,10 +262,19 @@ public class SlackServiceTests var sutProvider = GetSutProvider(); var clientId = sutProvider.GetDependency().Slack.ClientId; var scopes = sutProvider.GetDependency().Slack.Scopes; - var redirectUrl = "https://example.com/callback"; - var expectedUrl = $"https://slack.com/oauth/v2/authorize?client_id={clientId}&scope={scopes}&redirect_uri={redirectUrl}"; - var result = sutProvider.Sut.GetRedirectUrl(redirectUrl); - Assert.Equal(expectedUrl, result); + var callbackUrl = "https://example.com/callback"; + var state = Guid.NewGuid().ToString(); + var result = sutProvider.Sut.GetRedirectUrl(callbackUrl, state); + + var uri = new Uri(result); + var query = HttpUtility.ParseQueryString(uri.Query); + + Assert.Equal(clientId, query["client_id"]); + Assert.Equal(scopes, query["scope"]); + Assert.Equal(callbackUrl, query["redirect_uri"]); + Assert.Equal(state, query["state"]); + Assert.Equal("slack.com", uri.Host); + Assert.Equal("/oauth/v2/authorize", uri.AbsolutePath); } [Fact]