diff --git a/bitwarden-server.sln b/bitwarden-server.sln index 6786ad610c..055811478d 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29102.190 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36705.20 d17.14 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src - AGPL", "src - AGPL", "{DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D}" EndProject @@ -11,19 +11,19 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{DD5BD056-4 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{458155D3-BCBC-481D-B37A-40D2ED10F0A4}" ProjectSection(SolutionItems) = preProject + .dockerignore = .dockerignore + .editorconfig = .editorconfig + .gitignore = .gitignore + CONTRIBUTING.md = CONTRIBUTING.md Directory.Build.props = Directory.Build.props global.json = global.json - .gitignore = .gitignore - README.md = README.md - .editorconfig = .editorconfig - TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md - SECURITY.md = SECURITY.md - LICENSE_FAQ.md = LICENSE_FAQ.md - LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt - LICENSE_AGPL.txt = LICENSE_AGPL.txt LICENSE.txt = LICENSE.txt - CONTRIBUTING.md = CONTRIBUTING.md - .dockerignore = .dockerignore + LICENSE_AGPL.txt = LICENSE_AGPL.txt + LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt + LICENSE_FAQ.md = LICENSE_FAQ.md + README.md = README.md + SECURITY.md = SECURITY.md + TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "src\Core\Core.csproj", "{3973D21B-A692-4B60-9B70-3631C057423A}" @@ -134,10 +134,13 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sso.IntegrationTest", "bitwarden_license\test\Sso.IntegrationTest\Sso.IntegrationTest.csproj", "{FFB09376-595B-6F93-36F0-70CAE90AFECB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -354,6 +357,10 @@ Global {7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.Build.0 = Release|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -411,6 +418,7 @@ Global {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} + {FFB09376-595B-6F93-36F0-70CAE90AFECB} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index afbef321a9..dde2ac7a46 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -462,6 +462,7 @@ public class AccountController : Controller // FIXME: Update this file to be null safe and then delete the line below #nullable disable var provider = result.Properties.Items["scheme"]; + //Todo: Validate provider is a valid GUID with TryParse instead. When this is invalid it throws an exception var orgId = new Guid(provider); var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId); if (ssoConfig == null || !ssoConfig.Enabled) @@ -615,7 +616,7 @@ public class AccountController : Controller // Since we're in the auto-provisioning logic, this means that the user exists, but they have not // authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them). - // We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed + // We've verified that the user is Accepted or Confirmed, so we can create an SsoUser link and proceed // with authentication. await CreateSsoUserRecordAsync(providerUserId, guaranteedExistingUser.Id, organization.Id, guaranteedOrgUser); diff --git a/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs b/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs new file mode 100644 index 0000000000..7a1c9f9628 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Controllers/AccountControllerTests.cs @@ -0,0 +1,952 @@ +using System.Net; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Sso.IntegrationTest.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using Bitwarden.License.Test.Sso.IntegrationTest.Utilities; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Testing; +using NSubstitute; +using Xunit; +using AuthenticationSchemes = Bit.Core.AuthenticationSchemes; + +namespace Bit.Sso.IntegrationTest.Controllers; + +public class AccountControllerTests(SsoApplicationFactory factory) : IClassFixture +{ + private readonly SsoApplicationFactory _factory = factory; + + /* + * Test to verify the /Account/ExternalCallback endpoint exists and is reachable. + */ + [Fact] + public async Task ExternalCallback_EndpointExists_ReturnsExpectedStatusCode() + { + // Arrange + var client = _factory.CreateClient(); + + // Act - Verify the endpoint is accessible (even if it fails due to missing auth) + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - The endpoint should exist and return 500 (not 404) due to missing authentication + Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); + } + + /* + * Test to verify calling /Account/ExternalCallback without an authentication cookie + * results in an error as expected. + */ + [Fact] + public async Task ExternalCallback_WithNoAuthenticationCookie_ReturnsError() + { + // Arrange + var client = _factory.CreateClient(); + + // Act - Call ExternalCallback without proper authentication setup + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because there's no external authentication cookie + Assert.False(response.IsSuccessStatusCode); + // The endpoint will throw an exception when authentication fails + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify behavior of /Account/ExternalCallback with PM24579 feature flag + */ + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ExternalCallback_WithPM24579FeatureFlag_AndNoAuthCookie_ReturnsError + ( + bool featureFlagEnabled + ) + { + // Arrange + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(featureFlagEnabled); + services.AddSingleton(featureService); + }); + }).CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert + Assert.False(response.IsSuccessStatusCode); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify behavior of /Account/ExternalCallback simulating failed authentication. + */ + [Fact] + public async Task ExternalCallback_WithMockedAuthenticationService_FailedAuth_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithFailedAuthentication() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert + Assert.False(response.IsSuccessStatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when SSO config exists but is disabled. + */ + [Fact] + public async Task ExternalCallback_WithDisabledSsoConfig_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig(ssoConfig => ssoConfig!.Enabled = false) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because SSO config is disabled + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Organization not found or SSO configuration not enabled", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + [Fact] + public async Task ExternalCallback_FindUserFromExternalProviderAsync_OrganizationOrSsoConfigNotFound_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user has invalid status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Organization not found or SSO configuration not enabled", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when SSO config expects an ACR value + * but the authentication response has a missing or invalid ACR claim. + */ + [Fact] + public async Task ExternalCallback_WithExpectedAcrValue_AndInvalidAcr_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig(ssoConfig => ssoConfig!.SetData( + new SsoConfigurationData + { + ExpectedReturnAcrValue = "urn:expected:acr:value" + })) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because ACR claim is missing or invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Expected authentication context class reference (acr) was not returned with the authentication response or is invalid", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when the authentication response + * does not contain any recognizable user ID claim (sub, NameIdentifier, uid, upn, eppn). + */ + [Fact] + public async Task ExternalCallback_WithNoUserIdClaim_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .OmitProviderUserId() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); ; + + // Assert - Should fail because no user ID claim was found + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Unknown userid", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when no email claim is found + * and the providerUserId cannot be used as a fallback email (doesn't contain @). + */ + [Fact] + public async Task ExternalCallback_WithNoEmailClaim_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithNullEmail() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because no email claim was found + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Cannot find email claim", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * uses Key Connector but has no org user record (was removed from organization). + */ + [Fact] + public async Task ExternalCallback_WithExistingKeyConnectorUser_AndNoOrgUser_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser(user => + { + user.UsesKeyConnector = true; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user uses Key Connector but has no org user record + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("You were removed from the organization managing single sign-on for your account", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * uses Key Connector and has an org user record in the invited status. + */ + [Fact] + public async Task ExternalCallback_WithExistingKeyConnectorUser_AndInvitedOrgUser_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig(ssoConfig => { }) + .WithUser(user => + { + user.UsesKeyConnector = true; + }) + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Invited; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user uses Key Connector but the Org user is in the invited status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("You were removed from the organization managing single sign-on for your account", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * (not using Key Connector) has no org user record - they were removed from the organization. + */ + [Fact] + public async Task ExternalCallback_WithExistingUser_AndNoOrgUser_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user exists but has no org user record + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("You were removed from the organization managing single sign-on for your account. Contact the organization administrator", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when an existing user + * has an org user record with Invited status - they must accept the invite first. + */ + [Fact] + public async Task ExternalCallback_WithExistingUser_AndInvitedOrgUserStatus_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Invited; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user must accept invite before using SSO + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("you must first log in using your master password", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when organization has no available seats + * and cannot auto-scale because it's a self-hosted instance. + */ + [Fact] + public async Task ExternalCallback_WithNoAvailableSeats_OnSelfHosted_ReturnsError() + { + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithOrganization(org => + { + org.Seats = 5; // Organization has seat limit + }) + .AsSelfHosted() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because no seats available and cannot auto-scale on self-hosted + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("No seats available for organization", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when organization has no available seats + * and auto-scaling fails (e.g., billing issue, max seats reached). + */ + [Fact] + public async Task ExternalCallback_WithNoAvailableSeats_AndAutoAddSeatsFails_ReturnsError() + { + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithOrganization(org => + { + org.Seats = 5; + org.MaxAutoscaleSeats = 5; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because auto-adding seats failed + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("No seats available for organization", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when email cannot be found + * during new user provisioning (Scenario 2) after bypassing the first email check + * via manual linking path (userIdentifier is set). + */ + [Fact] + public async Task ExternalCallback_WithUserIdentifier_AndNoEmail_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUserIdentifier("") + .WithNullEmail() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because email cannot be found during new user provisioning + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Cannot find email claim", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when org user has an unknown/invalid status. + * This tests defensive code that handles future enum values or data corruption scenarios. + * We simulate this by casting an invalid integer to OrganizationUserStatusType. + */ + [Fact] + public async Task ExternalCallback_WithUnknownOrgUserStatus_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = (OrganizationUserStatusType)99; // Invalid enum value - simulates future status or data corruption + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because org user status is unknown/invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("is in an unknown state", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + // Note: "User should be found ln 304" appears to be unreachable defensive code. + // CreateUserAndOrgUserConditionallyAsync always returns a non-null user or throws an exception, + // so possibleSsoLinkedUser cannot be null when the feature flag check executes. + + /* + * Test to verify /Account/ExternalCallback returns error when userIdentifier + * is malformed (doesn't contain comma separator for userId,token format). + * There is only a single test case here but in the future we may need to expand the + * tests to cover other invalid formats. + */ + [Theory] + [BitAutoData("No-Comas-Identifier")] + public async Task ExternalCallback_WithInvalidUserIdentifierFormat_ReturnsError( + string UserIdentifier + ) + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUserIdentifier(UserIdentifier) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because userIdentifier format is invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Invalid user identifier", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when userIdentifier + * contains valid userId but invalid/mismatched token. + * + * NOTE: This test uses the substitute pattern instead of SsoTestDataBuilder because: + * - The userIdentifier in the auth result must contain a userId that matches a user in the system + * - User.SetNewId() always overwrites the Id (unlike Organization.SetNewId() which has a guard) + * - This means we cannot pre-set a User.Id before database insertion + * - The auth mock must be configured BEFORE accessing factory.Services (required by SubstituteService) + * - Therefore, we cannot coordinate the userId between the auth mock and the seeded user + * - Using substitutes allows us to control the exact userId and mock UserManager.VerifyUserTokenAsync + */ + [Fact] + public async Task ExternalCallback_WithUserIdentifier_AndInvalidToken_ReturnsError() + { + // Arrange + var organizationId = Guid.NewGuid(); + var providerUserId = Guid.NewGuid().ToString(); + var userId = Guid.NewGuid(); + var testEmail = "test_user@integration.test"; + var testName = "Test User"; + // Valid format but token won't verify + var userIdentifier = $"{userId},invalid-token"; + + var claimedUser = new User + { + Id = userId, + Email = testEmail, + Name = testName + }; + + var organization = new Organization + { + Id = organizationId, + Name = "Test Organization", + Enabled = true, + UseSso = true + }; + + var ssoConfig = new SsoConfig + { + OrganizationId = organizationId, + Enabled = true + }; + ssoConfig.SetData(new SsoConfigurationData()); + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); + services.AddSingleton(featureService); + + // Mock organization repository + var orgRepo = Substitute.For(); + orgRepo.GetByIdAsync(organizationId).Returns(organization); + orgRepo.GetByIdentifierAsync(organizationId.ToString()).Returns(organization); + services.AddSingleton(orgRepo); + + // Mock SSO config repository + var ssoConfigRepo = Substitute.For(); + ssoConfigRepo.GetByOrganizationIdAsync(organizationId).Returns(ssoConfig); + services.AddSingleton(ssoConfigRepo); + + // Mock user repository - no existing user via SSO + var userRepo = Substitute.For(); + userRepo.GetBySsoUserAsync(providerUserId, organizationId).Returns((User?)null); + services.AddSingleton(userRepo); + + // Mock user service - returns user for manual linking lookup + var userService = Substitute.For(); + userService.GetUserByIdAsync(userId.ToString()).Returns(claimedUser); + services.AddSingleton(userService); + + // Mock UserManager to return false for token verification + var userManager = Substitute.For>( + Substitute.For>(), null, null, null, null, null, null, null, null); + userManager.VerifyUserTokenAsync( + claimedUser, + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(false); + services.AddSingleton(userManager); + + // Mock authentication service with userIdentifier that has valid format but invalid token + var authService = Substitute.For(); + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(MockSuccessfulAuthResult.Build(organizationId, providerUserId, testEmail, testName, null, userIdentifier)); + services.AddSingleton(authService); + }); + }).CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because token verification failed + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Supplied userId and token did not match", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is enabled. + */ + [Fact] + public async Task ExternalCallback_WithRevokedOrgUser_WithPM24579FeatureFlagEnabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Revoked; + }) + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user state is invalid + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains( + $"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.", + stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is disabled. + */ + [Fact] + public async Task ExternalCallback_WithRevokedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Revoked; + }) + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user has invalid status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains( + $"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.", + stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error for invited org user when PM24579 feature flag is disabled. + */ + [Fact] + public async Task ExternalCallback_WithInvitedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Invited; + }) + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because user has invalid status + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains( + $"To accept your invite to {testData.Organization?.DisplayName()}, you must first log in using your master password. Once your invite has been accepted, you will be able to log in using SSO.", + stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + + /* + * Test to verify /Account/ExternalCallback returns error when user is found via SSO + * but has no organization user record (with feature flag enabled). + */ + [Fact] + public async Task ExternalCallback_WithSsoUser_AndNoOrgUser_WithFeatureFlagEnabled_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithSsoUser() + .WithFeatureFlags(factoryService => + { + factoryService.SubstituteService(srv => + { + srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true); + }); + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because org user cannot be found + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Could not find organization user", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when the provider scheme + * is not a valid GUID (SSOProviderIsNotAnOrgId). + * + * NOTE: This test uses the substitute pattern instead of SsoTestDataBuilder because: + * - Organization.Id is of type Guid and cannot be set to a non-GUID value + * - The auth mock scheme must be a non-GUID string to trigger this error path + * - This cannot be tested since ln 438 in AccountController.FindUserFromExternalProviderAsync throws a different exception + * before reaching the organization lookup exception. + */ + [Fact(Skip = "This test cannot be executed because the organization ID must be a GUID. See note in test summary.")] + public async Task ExternalCallback_WithInvalidProviderGuid_ReturnsError() + { + // Arrange + var invalidScheme = "not-a-valid-guid"; + var providerUserId = Guid.NewGuid().ToString(); + var testEmail = "test@example.com"; + var testName = "Test User"; + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + // Mock authentication service with invalid (non-GUID) scheme + var authService = Substitute.For(); + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(MockSuccessfulAuthResult.Build(invalidScheme, providerUserId, testEmail, testName)); + services.AddSingleton(authService); + }); + }).CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because provider is not a valid organization GUID + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Organization not found from identifier.", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * Test to verify /Account/ExternalCallback returns error when the organization ID + * in the auth result does not match any organization in the database. + * NOTE: This code path is unreachable because the SsoConfig must exist to proceed, but there is a circular dependency: + * - SsoConfig cannot exist without a valid Organization but the test is testing that an Organization cannot be found. + */ + [Fact(Skip = "This code path is unreachable because the SsoConfig must exist to proceed. But the SsoConfig cannot exist without a valid Organization.")] + public async Task ExternalCallback_WithNonExistentOrganization_ReturnsError() + { + // Arrange + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithNonExistentOrganizationInAuth() + .BuildAsync(); + + var client = testData.Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should fail because organization cannot be found by the ID in auth result + var stringResponse = await response.Content.ReadAsStringAsync(); + Assert.Contains("Could not find organization", stringResponse); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing + * SSO-linked user logs in (user exists in SsoUser table). + */ + [Fact] + public async Task ExternalCallback_WithExistingSsoUser_ReturnsSuccess() + { + // Arrange - User with SSO link already exists + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser() + .WithSsoUser() + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when JIT provisioning + * a new user (user doesn't exist, gets created automatically). + */ + [Fact] + public async Task ExternalCallback_WithJitProvisioning_ReturnsSuccess() + { + // Arrange - No user, no org user - JIT provisioning will create both + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing user + * with a valid (Confirmed) organization user status logs in via SSO for the first time. + */ + [Fact] + public async Task ExternalCallback_WithExistingUserAndConfirmedOrgUser_ReturnsSuccess() + { + // Arrange - Existing user with confirmed org user status, no SSO link yet + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Confirmed; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing user + * with Accepted organization user status logs in via SSO. + */ + [Fact] + public async Task ExternalCallback_WithExistingUserAndAcceptedOrgUser_ReturnsSuccess() + { + // Arrange - Existing user with accepted org user status + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser(orgUser => + { + orgUser.Status = OrganizationUserStatusType.Accepted; + }) + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false // Prevent auto-redirects to capture initial response + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Should succeed and redirect + Assert.True( + response.StatusCode == HttpStatusCode.Redirect, + $"Expected success/redirect but got {response.StatusCode}"); + + Assert.NotNull(response.Headers.Location); + } + + /* + * SUCCESS PATH: Test to verify /Account/ExternalCallback returns a View with 200 status + * when the client is a native application (uses custom URI scheme like "bitwarden://callback"). + * Native clients get a different response for better UX - a 200 with redirect view instead of 302. + * See AccountController lines 371-378. + */ + [Fact] + public async Task ExternalCallback_WithNativeClient_ReturnsViewWith200Status() + { + // Arrange - Existing SSO user with native client context + var testData = await new SsoTestDataBuilder() + .WithSsoConfig() + .WithUser() + .WithOrganizationUser() + .WithSsoUser() + .AsNativeClient() + .BuildAsync(); + + var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + // Act + var response = await client.GetAsync("/Account/ExternalCallback"); + + // Assert - Native clients get 200 status with a redirect view instead of 302 + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + // The Location header should be empty for native clients (set in controller) + // and the response should contain the redirect view + var content = await response.Content.ReadAsStringAsync(); + Assert.NotEmpty(content); // View content should be present + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/Properties/launchSettings.json b/bitwarden_license/test/Sso.IntegrationTest/Properties/launchSettings.json new file mode 100644 index 0000000000..63637a5304 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Sso.IntegrationTest": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:59973;http://localhost:59974" + } + } +} \ No newline at end of file diff --git a/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj b/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj new file mode 100644 index 0000000000..42d0743d51 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Sso.IntegrationTest.csproj @@ -0,0 +1,41 @@ + + + + net8.0 + enable + enable + + false + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + true + PreserveNewest + Never + + + + \ No newline at end of file diff --git a/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoApplicationFactory.cs b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoApplicationFactory.cs new file mode 100644 index 0000000000..656c045284 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoApplicationFactory.cs @@ -0,0 +1,11 @@ +using Bit.IntegrationTestCommon.Factories; + +namespace Bit.Sso.IntegrationTest.Utilities; + +public class SsoApplicationFactory : WebApplicationFactoryBase +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs new file mode 100644 index 0000000000..95f2387af2 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SsoTestDataBuilder.cs @@ -0,0 +1,327 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Settings; +using Bitwarden.License.Test.Sso.IntegrationTest.Utilities; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Authentication; +using NSubstitute; +using AuthenticationSchemes = Bit.Core.AuthenticationSchemes; + +namespace Bit.Sso.IntegrationTest.Utilities; + +/// +/// Contains the factory and all entities created by for use in integration tests. +/// +public record SsoTestData( + SsoApplicationFactory Factory, + Organization? Organization, + User? User, + OrganizationUser? OrganizationUser, + SsoConfig? SsoConfig, + SsoUser? SsoUser); + +/// +/// Builder for creating SSO test data with seeded database entities. +/// +public class SsoTestDataBuilder +{ + /// + /// This UserIdentifier is a mock for the UserIdentifier we get from the External Identity Provider. + /// + private string? _userIdentifier; + private Action? _organizationConfig; + private Action? _userConfig; + private Action? _orgUserConfig; + private Action? _ssoConfigConfig; + private Action? _ssoUserConfig; + private Action? _featureFlagConfig; + + private bool _includeUser = false; + private bool _includeSsoUser = false; + private bool _includeOrganizationUser = false; + private bool _includeSsoConfig = false; + private bool _successfulAuth = true; + private bool _withNullEmail = false; + private bool _isSelfHosted = false; + private bool _includeProviderUserId = true; + private bool _useNonExistentOrgInAuth = false; + private bool _isNativeClient = false; + + public SsoTestDataBuilder WithOrganization(Action configure) + { + _organizationConfig = configure; + return this; + } + + public SsoTestDataBuilder WithUser(Action? configure = null) + { + _includeUser = true; + _userConfig = configure; + return this; + } + + public SsoTestDataBuilder WithOrganizationUser(Action? configure = null) + { + _includeOrganizationUser = true; + _orgUserConfig = configure; + return this; + } + + public SsoTestDataBuilder WithSsoConfig(Action? configure = null) + { + _includeSsoConfig = true; + _ssoConfigConfig = configure; + return this; + } + + public SsoTestDataBuilder WithSsoUser(Action? configure = null) + { + _includeSsoUser = true; + _ssoUserConfig = configure; + return this; + } + + public SsoTestDataBuilder WithFeatureFlags(Action configure) + { + _featureFlagConfig = configure; + return this; + } + + public SsoTestDataBuilder WithFailedAuthentication() + { + _successfulAuth = false; + return this; + } + + public SsoTestDataBuilder WithNullEmail() + { + _withNullEmail = true; + return this; + } + + public SsoTestDataBuilder WithUserIdentifier(string userIdentifier) + { + _userIdentifier = userIdentifier; + return this; + } + + public SsoTestDataBuilder OmitProviderUserId() + { + _includeProviderUserId = false; + return this; + } + + public SsoTestDataBuilder AsSelfHosted() + { + _isSelfHosted = true; + return this; + } + + /// + /// Causes the auth result to use a different (non-existent) organization ID than what is seeded + /// in the database. This simulates the "organization not found" scenario. + /// + public SsoTestDataBuilder WithNonExistentOrganizationInAuth() + { + _useNonExistentOrgInAuth = true; + return this; + } + + /// + /// Configures the test to simulate a native client (non-browser) OIDC flow. + /// Native clients use custom URI schemes (e.g., "bitwarden://callback") instead of http/https. + /// This causes ExternalCallback to return a View with 200 status instead of a redirect. + /// + public SsoTestDataBuilder AsNativeClient() + { + _isNativeClient = true; + return this; + } + + public async Task BuildAsync() + { + // Create factory + var factory = new SsoApplicationFactory(); + + // Pre-generate IDs and values needed for auth mock (before accessing Services) + var organizationId = Guid.NewGuid(); + // Use a different org ID in auth if testing "organization not found" scenario + var authOrganizationId = _useNonExistentOrgInAuth ? Guid.NewGuid() : organizationId; + var providerUserId = _includeProviderUserId ? Guid.NewGuid().ToString() : ""; + var userEmail = _withNullEmail ? null : $"user_{Guid.NewGuid()}@test.com"; + var userName = "TestUser"; + + // 1. Configure mocked authentication service BEFORE accessing Services + factory.SubstituteService(authService => + { + if (_successfulAuth) + { + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(MockSuccessfulAuthResult.Build( + authOrganizationId, + providerUserId, + userEmail, + userName, + acrValue: null, + _userIdentifier)); + } + else + { + authService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(AuthenticateResult.Fail("External authentication error")); + } + }); + + // 1.a Configure GlobalSettings for Self-Hosted and seat limit + factory.SubstituteService(globalSettings => + { + globalSettings.SelfHosted.Returns(_isSelfHosted); + }); + + // 1.b configure setting feature flags + _featureFlagConfig?.Invoke(factory); + + // 1.c Configure IIdentityServerInteractionService for native client flow + if (_isNativeClient) + { + factory.SubstituteService(interaction => + { + // Native clients have redirect URIs that don't start with http/https + // e.g., "bitwarden://callback" or "com.bitwarden.app://callback" + var authorizationRequest = new AuthorizationRequest + { + RedirectUri = "bitwarden://sso-callback" + }; + interaction.GetAuthorizationContextAsync(Arg.Any()) + .Returns(authorizationRequest); + }); + } + + if (!_successfulAuth) + { + return new SsoTestData(factory, null!, null!, null!, null!, null!); + } + + // 2. Create Organization with defaults (using pre-generated ID) + var organization = new Organization + { + Id = organizationId, + Name = "Test Organization", + BillingEmail = "billing@test.com", + Plan = "Enterprise", + Enabled = true, + UseSso = true + }; + _organizationConfig?.Invoke(organization); + + var orgRepo = factory.Services.GetRequiredService(); + organization = await orgRepo.CreateAsync(organization); + + // 3. Create User with defaults (using pre-generated values) + User? user = null; + if (_includeUser) + { + user = new User + { + Email = userEmail ?? $"email_{Guid.NewGuid()}@test.dev", + Name = userName, + ApiKey = Guid.NewGuid().ToString(), + SecurityStamp = Guid.NewGuid().ToString() + }; + _userConfig?.Invoke(user); + + var userRepo = factory.Services.GetRequiredService(); + user = await userRepo.CreateAsync(user); + } + + // 4. Create OrganizationUser linking them + OrganizationUser? orgUser = null; + if (_includeOrganizationUser) + { + orgUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = user!.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User + }; + _orgUserConfig?.Invoke(orgUser); + + var orgUserRepo = factory.Services.GetRequiredService(); + orgUser = await orgUserRepo.CreateAsync(orgUser); + } + + // 4.a Create many OrganizationUser to test seat count logic + if (organization.Seats > 1) + { + var orgUserRepo = factory.Services.GetRequiredService(); + var userRepo = factory.Services.GetRequiredService(); + var additionalOrgUsers = new List(); + for (var i = 1; i <= organization.Seats; i++) + { + var additionalUser = new User + { + Email = $"additional_user_{i}_{Guid.NewGuid()}@test.dev", + Name = $"AdditionalUser{i}", + ApiKey = Guid.NewGuid().ToString(), + SecurityStamp = Guid.NewGuid().ToString() + }; + var createdAdditionalUser = await userRepo.CreateAsync(additionalUser); + + var additionalOrgUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = createdAdditionalUser.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User + }; + additionalOrgUsers.Add(additionalOrgUser); + } + await orgUserRepo.CreateManyAsync(additionalOrgUsers); + } + + // 5. Create SsoConfig, if ssoConfigConfig is not null + SsoConfig? ssoConfig = null; + if (_includeSsoConfig) + { + ssoConfig = new SsoConfig + { + OrganizationId = authOrganizationId, + Enabled = true + }; + ssoConfig.SetData(new SsoConfigurationData()); + _ssoConfigConfig?.Invoke(ssoConfig); + + var ssoConfigRepo = factory.Services.GetRequiredService(); + ssoConfig = await ssoConfigRepo.CreateAsync(ssoConfig); + } + + // 6. Optionally create SsoUser (using pre-generated providerUserId as ExternalId) + SsoUser? ssoUser = null; + if (_includeSsoUser) + { + ssoUser = new SsoUser + { + OrganizationId = organization.Id, + UserId = user!.Id, + ExternalId = providerUserId + }; + _ssoUserConfig?.Invoke(ssoUser); + + var ssoUserRepo = factory.Services.GetRequiredService(); + ssoUser = await ssoUserRepo.CreateAsync(ssoUser); + } + + return new SsoTestData(factory, organization, user, orgUser, ssoConfig, ssoUser); + } +} diff --git a/bitwarden_license/test/Sso.IntegrationTest/Utilities/SuccessfulAuthResult.cs b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SuccessfulAuthResult.cs new file mode 100644 index 0000000000..72f5738ad9 --- /dev/null +++ b/bitwarden_license/test/Sso.IntegrationTest/Utilities/SuccessfulAuthResult.cs @@ -0,0 +1,88 @@ +using System.Security.Claims; +using Bit.Core; +using Duende.IdentityModel; +using Microsoft.AspNetCore.Authentication; + +namespace Bitwarden.License.Test.Sso.IntegrationTest.Utilities; + +/// +/// Creates a mock for use in tests requiring a valid external authentication result. +/// +internal static class MockSuccessfulAuthResult +{ + /// + /// Since this tests the external Authentication flow, only the OrganizationId is strictly required. + /// However, some tests may require additional claims to be present, so they can be optionally added. + /// + /// + /// + /// + /// + /// + /// + /// + public static AuthenticateResult Build( + Guid organizationId, + string? providerUserId, + string? email, + string? name = null, + string? acrValue = null, + string? userIdentifier = null) + { + return Build(organizationId.ToString(), providerUserId, email, name, acrValue, userIdentifier); + } + + /// + /// Overload that accepts a custom scheme string. Useful for testing invalid provider scenarios + /// where the scheme is not a valid GUID. + /// + public static AuthenticateResult Build( + string scheme, + string? providerUserId, + string? email, + string? name = null, + string? acrValue = null, + string? userIdentifier = null) + { + var claims = new List(); + + if (!string.IsNullOrEmpty(email)) + { + claims.Add(new Claim(JwtClaimTypes.Email, email)); + } + + if (!string.IsNullOrEmpty(providerUserId)) + { + claims.Add(new Claim(JwtClaimTypes.Subject, providerUserId)); + } + + if (!string.IsNullOrEmpty(name)) + { + claims.Add(new Claim(JwtClaimTypes.Name, name)); + } + + if (!string.IsNullOrEmpty(acrValue)) + { + claims.Add(new Claim(JwtClaimTypes.AuthenticationContextClassReference, acrValue)); + } + + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "External")); + var properties = new AuthenticationProperties + { + Items = + { + ["scheme"] = scheme, + ["return_url"] = "~/", + ["state"] = "test-state", + ["user_identifier"] = userIdentifier ?? string.Empty + } + }; + + var ticket = new AuthenticationTicket( + principal, + properties, + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); + + return AuthenticateResult.Success(ticket); + } +} diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index 3c0b551908..ba12d1e1f4 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -189,7 +189,7 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase /// Registers a new user to the Identity Application Factory based on the RegisterFinishRequestModel /// /// RegisterFinishRequestModel needed to seed data to the test user - /// optional parameter that is tracked during the inital steps of registration. + /// optional parameter that is tracked during the initial steps of registration. /// returns the newly created user public async Task RegisterNewIdentityFactoryUserAsync( RegisterFinishRequestModel requestModel, diff --git a/test/IntegrationTestCommon/IntegrationTestCommon.csproj b/test/IntegrationTestCommon/IntegrationTestCommon.csproj index a20a14f222..7e04d29248 100644 --- a/test/IntegrationTestCommon/IntegrationTestCommon.csproj +++ b/test/IntegrationTestCommon/IntegrationTestCommon.csproj @@ -3,12 +3,12 @@ false - + - +