[PM-25949] ExternalCallback Integration tests for SSO Project (#6809)
Some checks are pending
Collect code references / Check for secret access (push) Waiting to run
Collect code references / Code reference collection (push) Blocked by required conditions
Scan / Sonar (push) Blocked by required conditions
Scan / Check PR run (push) Waiting to run
Scan / Checkmarx (push) Blocked by required conditions
Testing / Run tests (push) Waiting to run

* feat: add new integration test project

* test: add factory for SSO application; ExternalCallback integration tests.

* test: modified Integration tests to use seeded data instead of service substitutes with mocked responses, where possible.

* fix: re-organize projects in solution. SsoFactory now in its owning project with SSO integration test which match the integration test factory pattern more closely.

* claude: better naming of class fields.
This commit is contained in:
Ike 2026-01-10 09:02:50 -05:00 committed by GitHub
parent e705fe3f3f
commit 5320878295
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1456 additions and 16 deletions

View file

@ -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}

View file

@ -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);

View file

@ -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<SsoApplicationFactory>
{
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<IFeatureService>();
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<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true);
services.AddSingleton(featureService);
// Mock organization repository
var orgRepo = Substitute.For<IOrganizationRepository>();
orgRepo.GetByIdAsync(organizationId).Returns(organization);
orgRepo.GetByIdentifierAsync(organizationId.ToString()).Returns(organization);
services.AddSingleton(orgRepo);
// Mock SSO config repository
var ssoConfigRepo = Substitute.For<ISsoConfigRepository>();
ssoConfigRepo.GetByOrganizationIdAsync(organizationId).Returns(ssoConfig);
services.AddSingleton(ssoConfigRepo);
// Mock user repository - no existing user via SSO
var userRepo = Substitute.For<IUserRepository>();
userRepo.GetBySsoUserAsync(providerUserId, organizationId).Returns((User?)null);
services.AddSingleton(userRepo);
// Mock user service - returns user for manual linking lookup
var userService = Substitute.For<IUserService>();
userService.GetUserByIdAsync(userId.ToString()).Returns(claimedUser);
services.AddSingleton(userService);
// Mock UserManager to return false for token verification
var userManager = Substitute.For<UserManager<User>>(
Substitute.For<IUserStore<User>>(), null, null, null, null, null, null, null, null);
userManager.VerifyUserTokenAsync(
claimedUser,
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>())
.Returns(false);
services.AddSingleton(userManager);
// Mock authentication service with userIdentifier that has valid format but invalid token
var authService = Substitute.For<IAuthenticationService>();
authService.AuthenticateAsync(
Arg.Any<HttpContext>(),
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<IFeatureService>(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<IFeatureService>(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<IFeatureService>(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<IFeatureService>(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<IAuthenticationService>();
authService.AuthenticateAsync(
Arg.Any<HttpContext>(),
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
}
}

View file

@ -0,0 +1,12 @@
{
"profiles": {
"Sso.IntegrationTest": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:59973;http://localhost:59974"
}
}
}

View file

@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Sso\Sso.csproj" />
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="Properties\launchSettings.json">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

View file

@ -0,0 +1,11 @@
using Bit.IntegrationTestCommon.Factories;
namespace Bit.Sso.IntegrationTest.Utilities;
public class SsoApplicationFactory : WebApplicationFactoryBase<Startup>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
}
}

View file

@ -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;
/// <summary>
/// Contains the factory and all entities created by <see cref="SsoTestDataBuilder"/> for use in integration tests.
/// </summary>
public record SsoTestData(
SsoApplicationFactory Factory,
Organization? Organization,
User? User,
OrganizationUser? OrganizationUser,
SsoConfig? SsoConfig,
SsoUser? SsoUser);
/// <summary>
/// Builder for creating SSO test data with seeded database entities.
/// </summary>
public class SsoTestDataBuilder
{
/// <summary>
/// This UserIdentifier is a mock for the UserIdentifier we get from the External Identity Provider.
/// </summary>
private string? _userIdentifier;
private Action<Organization>? _organizationConfig;
private Action<User>? _userConfig;
private Action<OrganizationUser>? _orgUserConfig;
private Action<SsoConfig>? _ssoConfigConfig;
private Action<SsoUser>? _ssoUserConfig;
private Action<SsoApplicationFactory>? _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<Organization> configure)
{
_organizationConfig = configure;
return this;
}
public SsoTestDataBuilder WithUser(Action<User>? configure = null)
{
_includeUser = true;
_userConfig = configure;
return this;
}
public SsoTestDataBuilder WithOrganizationUser(Action<OrganizationUser>? configure = null)
{
_includeOrganizationUser = true;
_orgUserConfig = configure;
return this;
}
public SsoTestDataBuilder WithSsoConfig(Action<SsoConfig>? configure = null)
{
_includeSsoConfig = true;
_ssoConfigConfig = configure;
return this;
}
public SsoTestDataBuilder WithSsoUser(Action<SsoUser>? configure = null)
{
_includeSsoUser = true;
_ssoUserConfig = configure;
return this;
}
public SsoTestDataBuilder WithFeatureFlags(Action<SsoApplicationFactory> 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;
}
/// <summary>
/// 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.
/// </summary>
public SsoTestDataBuilder WithNonExistentOrganizationInAuth()
{
_useNonExistentOrgInAuth = true;
return this;
}
/// <summary>
/// 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.
/// </summary>
public SsoTestDataBuilder AsNativeClient()
{
_isNativeClient = true;
return this;
}
public async Task<SsoTestData> 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<IAuthenticationService>(authService =>
{
if (_successfulAuth)
{
authService.AuthenticateAsync(
Arg.Any<HttpContext>(),
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme)
.Returns(MockSuccessfulAuthResult.Build(
authOrganizationId,
providerUserId,
userEmail,
userName,
acrValue: null,
_userIdentifier));
}
else
{
authService.AuthenticateAsync(
Arg.Any<HttpContext>(),
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme)
.Returns(AuthenticateResult.Fail("External authentication error"));
}
});
// 1.a Configure GlobalSettings for Self-Hosted and seat limit
factory.SubstituteService<IGlobalSettings>(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<IIdentityServerInteractionService>(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<string>())
.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<IOrganizationRepository>();
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<IUserRepository>();
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<IOrganizationUserRepository>();
orgUser = await orgUserRepo.CreateAsync(orgUser);
}
// 4.a Create many OrganizationUser to test seat count logic
if (organization.Seats > 1)
{
var orgUserRepo = factory.Services.GetRequiredService<IOrganizationUserRepository>();
var userRepo = factory.Services.GetRequiredService<IUserRepository>();
var additionalOrgUsers = new List<OrganizationUser>();
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<ISsoConfigRepository>();
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<ISsoUserRepository>();
ssoUser = await ssoUserRepo.CreateAsync(ssoUser);
}
return new SsoTestData(factory, organization, user, orgUser, ssoConfig, ssoUser);
}
}

View file

@ -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;
/// <summary>
/// Creates a mock for use in tests requiring a valid external authentication result.
/// </summary>
internal static class MockSuccessfulAuthResult
{
/// <summary>
/// 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.
/// </summary>
/// <param name="organizationId"></param>
/// <param name="providerUserId"></param>
/// <param name="email"></param>
/// <param name="name"></param>
/// <param name="acrValue"></param>
/// <param name="userIdentifier"></param>
/// <returns></returns>
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);
}
/// <summary>
/// Overload that accepts a custom scheme string. Useful for testing invalid provider scenarios
/// where the scheme is not a valid GUID.
/// </summary>
public static AuthenticateResult Build(
string scheme,
string? providerUserId,
string? email,
string? name = null,
string? acrValue = null,
string? userIdentifier = null)
{
var claims = new List<Claim>();
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);
}
}

View file

@ -189,7 +189,7 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase<Startup>
/// Registers a new user to the Identity Application Factory based on the RegisterFinishRequestModel
/// </summary>
/// <param name="requestModel">RegisterFinishRequestModel needed to seed data to the test user</param>
/// <param name="marketingEmails">optional parameter that is tracked during the inital steps of registration.</param>
/// <param name="marketingEmails">optional parameter that is tracked during the initial steps of registration.</param>
/// <returns>returns the newly created user</returns>
public async Task<User> RegisterNewIdentityFactoryUserAsync(
RegisterFinishRequestModel requestModel,

View file

@ -3,12 +3,12 @@
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Identity\Identity.csproj" />
<ProjectReference Include="..\..\util\Migrator\Migrator.csproj" />