diff --git a/test/Identity.Test/AutoFixture/ProfileServiceFixtures.cs b/test/Identity.Test/AutoFixture/ProfileServiceFixtures.cs new file mode 100644 index 000000000000..3e6b7e931b99 --- /dev/null +++ b/test/Identity.Test/AutoFixture/ProfileServiceFixtures.cs @@ -0,0 +1,58 @@ +using System.Reflection; +using System.Security.Claims; +using AutoFixture; +using AutoFixture.Xunit2; +using Bit.Core.Auth.Identity; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.Test.AutoFixture; + +internal class ProfileDataRequestContextCustomization : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(o => o.Subject, new ClaimsPrincipal(new ClaimsIdentity([ + new Claim("sub", Guid.NewGuid().ToString()), + new Claim("name", "Test User"), + new Claim("email", "test@example.com") + ]))) + .With(o => o.Client, new Client { ClientId = "web" }) + .With(o => o.ValidatedRequest, () => null) + .With(o => o.RequestedResources, new ResourceValidationResult()) + .With(o => o.IssuedClaims, []) + .Without(o => o.Caller)); + } +} + +public class ProfileDataRequestContextAttribute : CustomizeAttribute +{ + public override ICustomization GetCustomization(ParameterInfo parameter) + { + return new ProfileDataRequestContextCustomization(); + } +} + +internal class IsActiveContextCustomization : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(o => o.Subject, new ClaimsPrincipal(new ClaimsIdentity([ + new Claim("sub", Guid.NewGuid().ToString()), + new Claim(Claims.SecurityStamp, "test-security-stamp") + ]))) + .With(o => o.Client, new Client { ClientId = "web" }) + .With(o => o.IsActive, false) + .Without(o => o.Caller)); + } +} + +public class IsActiveContextAttribute : CustomizeAttribute +{ + public override ICustomization GetCustomization(ParameterInfo parameter) + { + return new IsActiveContextCustomization(); + } +} diff --git a/test/Identity.Test/IdentityServer/ProfileServiceTests.cs b/test/Identity.Test/IdentityServer/ProfileServiceTests.cs new file mode 100644 index 000000000000..c20a240370ef --- /dev/null +++ b/test/Identity.Test/IdentityServer/ProfileServiceTests.cs @@ -0,0 +1,558 @@ +using System.Security.Claims; +using Bit.Core.AdminConsole.Context; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; +using Bit.Core.Billing.Services; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Identity.IdentityServer; +using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityServer.Models; +using NSubstitute; +using Xunit; +using AuthFixtures = Bit.Identity.Test.AutoFixture; + +namespace Bit.Identity.Test.IdentityServer; + +public class ProfileServiceTests +{ + private readonly IUserService _userService; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IProviderUserRepository _providerUserRepository; + private readonly IProviderOrganizationRepository _providerOrganizationRepository; + private readonly ILicensingService _licensingService; + private readonly ICurrentContext _currentContext; + private readonly ProfileService _sut; + + public ProfileServiceTests() + { + _userService = Substitute.For(); + _organizationUserRepository = Substitute.For(); + _providerUserRepository = Substitute.For(); + _providerOrganizationRepository = Substitute.For(); + _licensingService = Substitute.For(); + _currentContext = Substitute.For(); + + _sut = new ProfileService( + _userService, + _organizationUserRepository, + _providerUserRepository, + _providerOrganizationRepository, + _licensingService, + _currentContext); + } + + /// + /// For Bitwarden Sends, the zero-knowledge feature architecture is enforced by preserving claims as issued, + /// without attempting user lookup or claims mutation. + /// When acting on behalf of a Send client, the service preserves existing claims, including those issued + /// by the SendAccessGrantValidator, and returns without further claims lookup. + /// + /// + [Theory, BitAutoData] + public async Task GetProfileDataAsync_SendClient_PreservesExistingClaims( + [AuthFixtures.ProfileDataRequestContext] + ProfileDataRequestContext context) + { + context.Client.ClientId = BitwardenClient.Send; + var existingClaims = new[] + { + new Claim(Claims.SendAccessClaims.SendId, Guid.NewGuid().ToString()), new Claim("send_access", "test") + }; + context.Subject = new ClaimsPrincipal(new ClaimsIdentity(existingClaims)); + + await _sut.GetProfileDataAsync(context); + + Assert.Equal(existingClaims.Length, context.IssuedClaims.Count); + Assert.All(existingClaims, existingClaim => + Assert.Contains(context.IssuedClaims, issuedClaim => issuedClaim.Type == existingClaim.Type + && issuedClaim.Value == existingClaim.Value)); + } + + /// + /// For Bitwarden Sends, Send access tokens neither represent a user state nor require user profile data. + /// The SendAccessGrantValidator handles validity of requests, including resource passwords and 2FA. + /// Separation of concerns dictates that actions on behalf of Send clients should complete without + /// further lookup of user data. + /// + /// + [Theory, BitAutoData] + public async Task GetProfileDataAsync_SendClient_DoesNotCallUserService( + [AuthFixtures.ProfileDataRequestContext] + ProfileDataRequestContext context) + { + context.Client.ClientId = BitwardenClient.Send; + + await _sut.GetProfileDataAsync(context); + + await _userService.DidNotReceive().GetUserByPrincipalAsync(Arg.Any()); + } + + /// + /// For Bitwarden Sends, the client is treated as having always-active behavior, and is neither representative of + /// a user state nor requires user profile data. + /// + /// + [Theory, BitAutoData] + public async Task IsActiveAsync_SendClient_ReturnsTrue( + [AuthFixtures.IsActiveContext] IsActiveContext context) + { + context.Client.ClientId = BitwardenClient.Send; + context.IsActive = false; + + await _sut.IsActiveAsync(context); + + Assert.True(context.IsActive); + } + + /// + /// For Bitwarden Sends, the client should not interrogate the user principal as part of evaluating + /// whether it is active. + /// + [Theory, BitAutoData] + public async Task IsActiveAsync_SendClient_DoesNotCallUserService( + [AuthFixtures.IsActiveContext] IsActiveContext context) + { + context.Client.ClientId = BitwardenClient.Send; + + await _sut.IsActiveAsync(context); + + await _userService.DidNotReceive().GetUserByPrincipalAsync(Arg.Any()); + } + + /// + /// When IdentityServer issues a new access token or services a UserInfo request for a given user, + /// re-evaluate the claims for that user to ensure freshness. + /// Organization-specific claims should be filtered out if the user is null for any reason. + /// This allows users to continue acting on their own behalf from a valid authenticated state, but enforces + /// a security boundary which prevents leaking of organization data and ensures organization claims, + /// which are more likely to change than user claims, are accurate and not present if the user cannot be + /// verified. + /// + [Theory] + [BitAutoData(BitwardenClient.Web)] + [BitAutoData(BitwardenClient.Browser)] + [BitAutoData(BitwardenClient.Cli)] + [BitAutoData(BitwardenClient.Desktop)] + [BitAutoData(BitwardenClient.Mobile)] + [BitAutoData(BitwardenClient.DirectoryConnector)] + public async Task GetProfileDataAsync_UserNull_PreservesExistingNonOrgClaims( + string client, + [AuthFixtures.ProfileDataRequestContext] + ProfileDataRequestContext context) + { + context.Client.ClientId = client; + var existingClaims = new[] + { + new Claim("sub", Guid.NewGuid().ToString()), new Claim("email", "test@example.com"), + new Claim(Claims.OrganizationOwner, Guid.NewGuid().ToString()) // This should be filtered out + }; + context.Subject = new ClaimsPrincipal(new ClaimsIdentity(existingClaims)); + _userService.GetUserByPrincipalAsync(context.Subject).Returns((User)null); + + await _sut.GetProfileDataAsync(context); + + // Should preserve user claims + Assert.Contains(context.IssuedClaims, issuedClaim => issuedClaim.Type == "sub"); + Assert.Contains(context.IssuedClaims, issuedClaim => issuedClaim.Type == "email"); + // Should filter out organization-related claims + Assert.DoesNotContain(context.IssuedClaims, issuedClaim => issuedClaim.Type.StartsWith("org")); + } + + /// + /// When IdentityServer issues a new access token or services a UserInfo request for a given user, + /// re-evaluate the claims for that user to ensure freshness. + /// New or updated claims, including premium access and organization or provider membership, + /// should be served with the response. + /// + [Theory] + [BitAutoData(BitwardenClient.Web)] + [BitAutoData(BitwardenClient.Browser)] + [BitAutoData(BitwardenClient.Cli)] + [BitAutoData(BitwardenClient.Desktop)] + [BitAutoData(BitwardenClient.Mobile)] + [BitAutoData(BitwardenClient.DirectoryConnector)] + public async Task GetProfileDataAsync_UserExists_BuildsIdentityClaims( + string client, + [AuthFixtures.ProfileDataRequestContext] + ProfileDataRequestContext context, + User user) + { + context.Client.ClientId = client; + user.Id = Guid.Parse(context.Subject.FindFirst("sub")!.Value); + var orgMemberships = new List + { + new() { Id = Guid.NewGuid(), Type = OrganizationUserType.User } + }; + var providerMemberships = new List(); + + _userService.GetUserByPrincipalAsync(context.Subject).Returns(user); + _licensingService.ValidateUserPremiumAsync(user).Returns(true); + _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id) + .Returns(orgMemberships); + _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id) + .Returns(providerMemberships); + + await _sut.GetProfileDataAsync(context); + + Assert.NotEmpty(context.IssuedClaims); + Assert.Contains(context.IssuedClaims, + issuedClaim => issuedClaim.Type == Claims.Premium && + issuedClaim.Value.Equals("true", StringComparison.CurrentCultureIgnoreCase)); + await _licensingService.Received(1).ValidateUserPremiumAsync(user); + await _currentContext.Received(1).OrganizationMembershipAsync(_organizationUserRepository, user.Id); + await _currentContext.Received(1).ProviderMembershipAsync(_providerUserRepository, user.Id); + } + + /// + /// OpenID Connect Core and JWT distinguish between string and boolean types. For spec compliance, + /// boolean types should be served as booleans, not as strings (e.g., true, not "true"). See + /// https://datatracker.ietf.org/doc/html/rfc7159#section-3, and + /// https://datatracker.ietf.org/doc/html/rfc7519#section-2. + /// For proper claims deserialization and type safety, ensure boolean values are treated as + /// ClaimType.Boolean. + /// + [Theory] + [BitAutoData(BitwardenClient.Web)] + [BitAutoData(BitwardenClient.Browser)] + [BitAutoData(BitwardenClient.Cli)] + [BitAutoData(BitwardenClient.Desktop)] + [BitAutoData(BitwardenClient.Mobile)] + [BitAutoData(BitwardenClient.DirectoryConnector)] + public async Task GetProfileDataAsync_UserExists_BooleanClaimsHaveBooleanType( + string client, + [AuthFixtures.ProfileDataRequestContext] + ProfileDataRequestContext context, + User user) + { + context.Client.ClientId = client; + user.Id = Guid.Parse(context.Subject.FindFirst("sub").Value); + + _userService.GetUserByPrincipalAsync(context.Subject).Returns(user); + _licensingService.ValidateUserPremiumAsync(user).Returns(true); + _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id) + .Returns(new List()); + _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id) + .Returns(new List()); + + await _sut.GetProfileDataAsync(context); + + var booleanClaims = context.IssuedClaims.Where(claim => + claim.Value.Equals("true", StringComparison.OrdinalIgnoreCase) || + claim.Value.Equals("false", StringComparison.OrdinalIgnoreCase)); + + Assert.All(booleanClaims, claim => + Assert.Equal(ClaimValueTypes.Boolean, claim.ValueType)); + } + + /// + /// When IdentityServer issues a new access token or services a UserInfo request for a given user, + /// re-evaluate the claims for that user to ensure freshness. + /// Organization-specific claims should never be allowed to persist, and should always be fetched fresh. + /// + /// + [Theory] + [BitAutoData(BitwardenClient.Web)] + [BitAutoData(BitwardenClient.Browser)] + [BitAutoData(BitwardenClient.Cli)] + [BitAutoData(BitwardenClient.Desktop)] + [BitAutoData(BitwardenClient.Mobile)] + [BitAutoData(BitwardenClient.DirectoryConnector)] + public async Task GetProfileDataAsync_FiltersOutOrgClaimsFromExisting( + string client, + [AuthFixtures.ProfileDataRequestContext] + ProfileDataRequestContext context, + User user) + { + context.Client.ClientId = client; + user.Id = Guid.Parse(context.Subject.FindFirst("sub").Value); + + var existingClaims = new[] + { + new Claim(Claims.OrganizationOwner, Guid.NewGuid().ToString()), + new Claim(Claims.OrganizationAdmin, Guid.NewGuid().ToString()), new Claim("email", "test@example.com"), + new Claim("name", "Test User") + }; + context.Subject = new ClaimsPrincipal(new ClaimsIdentity(existingClaims)); + + _userService.GetUserByPrincipalAsync(context.Subject).Returns(user); + _licensingService.ValidateUserPremiumAsync(user).Returns(false); + _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id) + .Returns(new List()); + _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id) + .Returns(new List()); + + await _sut.GetProfileDataAsync(context); + + Assert.DoesNotContain(context.IssuedClaims, issuedClaim => issuedClaim.Type.StartsWith("org")); + Assert.Contains(context.IssuedClaims, issuedClaim => issuedClaim.Type == "email"); + Assert.Contains(context.IssuedClaims, issuedClaim => issuedClaim.Type == "name"); + } + + /// + /// When IdentityServer issues a new access token or services a UserInfo request for a given user, + /// re-evaluate the claims for that user to ensure freshness. + /// Existing claims should always be updated, even if their type exists in the incoming collection. + /// + [Theory] + [BitAutoData(BitwardenClient.Web)] + [BitAutoData(BitwardenClient.Browser)] + [BitAutoData(BitwardenClient.Cli)] + [BitAutoData(BitwardenClient.Desktop)] + [BitAutoData(BitwardenClient.Mobile)] + [BitAutoData(BitwardenClient.DirectoryConnector)] + public async Task GetProfileDataAsync_NewClaimsOverrideExistingNonOrgClaims( + string client, + [AuthFixtures.ProfileDataRequestContext] + ProfileDataRequestContext context, + User user) + { + context.Client.ClientId = client; + user.Id = Guid.Parse(context.Subject.FindFirst("sub").Value); + user.Email = "new@example.com"; + + var existingClaims = new[] + { + new Claim("sub", user.Id.ToString()), new Claim("email", "old@example.com"), + new Claim(Claims.Premium, "false") + }; + context.Subject = new ClaimsPrincipal(new ClaimsIdentity(existingClaims)); + + _userService.GetUserByPrincipalAsync(context.Subject).Returns(user); + _licensingService.ValidateUserPremiumAsync(user).Returns(true); + _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id) + .Returns(new List()); + _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id) + .Returns(new List()); + + await _sut.GetProfileDataAsync(context); + + // Should have new premium claim, not old one + Assert.Contains(context.IssuedClaims, + issuedClaim => issuedClaim.Type == Claims.Premium && + issuedClaim.Value.Equals("true", StringComparison.CurrentCultureIgnoreCase)); + Assert.DoesNotContain(context.IssuedClaims, + issuedClaim => issuedClaim.Type == Claims.Premium && + issuedClaim.Value.Equals("false", StringComparison.CurrentCultureIgnoreCase)); + + // Should have new email + Assert.Contains(context.IssuedClaims, + issuedClaim => issuedClaim.Type == "email" && issuedClaim.Value == "new@example.com"); + Assert.DoesNotContain(context.IssuedClaims, + issuedClaim => issuedClaim.Type == "email" && issuedClaim.Value == "old@example.com"); + } + + /// + /// Users may belong to multiple organizations. Claims should be properly scoped to each relevant organization + /// and not cross-pollinate claims across organizations, and should be fetched fresh on each request. + /// + /// + [Theory] + [BitAutoData(BitwardenClient.Web)] + [BitAutoData(BitwardenClient.Browser)] + [BitAutoData(BitwardenClient.Cli)] + [BitAutoData(BitwardenClient.Desktop)] + [BitAutoData(BitwardenClient.Mobile)] + [BitAutoData(BitwardenClient.DirectoryConnector)] + public async Task GetProfileDataAsync_WithMultipleOrganizations_IncludesOrgClaims( + string client, + [AuthFixtures.ProfileDataRequestContext] + ProfileDataRequestContext context, + User user) + { + context.Client.ClientId = client; + user.Id = Guid.Parse(context.Subject.FindFirst("sub").Value); + + var orgId1 = Guid.NewGuid(); + var orgId2 = Guid.NewGuid(); + var orgMemberships = new List + { + new() { Id = orgId1, Type = OrganizationUserType.Owner }, + new() { Id = orgId2, Type = OrganizationUserType.Admin } + }; + + _userService.GetUserByPrincipalAsync(context.Subject).Returns(user); + _licensingService.ValidateUserPremiumAsync(user).Returns(false); + _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id) + .Returns(orgMemberships); + _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id) + .Returns(new List()); + + await _sut.GetProfileDataAsync(context); + + Assert.Contains(context.IssuedClaims, + issuedClaim => issuedClaim.Type == Claims.OrganizationOwner && issuedClaim.Value == orgId1.ToString()); + Assert.Contains(context.IssuedClaims, + issuedClaim => issuedClaim.Type == Claims.OrganizationAdmin && issuedClaim.Value == orgId2.ToString()); + } + + /// + /// Users may belong to providers. Claims should be properly scoped to each relevant provider + /// and not cross-pollinate claims across providers, and should be fetched fresh on each request. + /// + /// + [Theory] + [BitAutoData(BitwardenClient.Web)] + [BitAutoData(BitwardenClient.Browser)] + [BitAutoData(BitwardenClient.Cli)] + [BitAutoData(BitwardenClient.Desktop)] + [BitAutoData(BitwardenClient.Mobile)] + [BitAutoData(BitwardenClient.DirectoryConnector)] + public async Task GetProfileDataAsync_WithProviders_IncludesProviderClaims( + string client, + [AuthFixtures.ProfileDataRequestContext] + ProfileDataRequestContext context, + User user) + { + context.Client.ClientId = client; + user.Id = Guid.Parse(context.Subject.FindFirst("sub").Value); + + var providerId = Guid.NewGuid(); + var providerMemberships = new List + { + new() { Id = providerId, Type = ProviderUserType.ProviderAdmin } + }; + + _userService.GetUserByPrincipalAsync(context.Subject).Returns(user); + _licensingService.ValidateUserPremiumAsync(user).Returns(false); + _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id) + .Returns(new List()); + _currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id) + .Returns(providerMemberships); + + await _sut.GetProfileDataAsync(context); + + Assert.Contains(context.IssuedClaims, issuedClaim => issuedClaim.Type.StartsWith("provider")); + } + + /// + /// Evaluates the happy path for the core session invalidation mechanism. + /// Critical events (e.g., password change) update the security stamp, and any subsequent request through + /// this service should expose the stamp as invalid. A found user and matching security stamp + /// prove out an active session. + /// + [Theory] + [BitAutoData(BitwardenClient.Web)] + [BitAutoData(BitwardenClient.Browser)] + [BitAutoData(BitwardenClient.Cli)] + [BitAutoData(BitwardenClient.Desktop)] + [BitAutoData(BitwardenClient.Mobile)] + [BitAutoData(BitwardenClient.DirectoryConnector)] + public async Task IsActiveAsync_SecurityStampMatches_ReturnsTrue( + string client, + [AuthFixtures.IsActiveContext] IsActiveContext context, + User user) + { + context.Client.ClientId = client; + var securityStamp = "matching-security-stamp"; + user.SecurityStamp = securityStamp; + + context.Subject = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim("sub", user.Id.ToString()), new Claim(Claims.SecurityStamp, securityStamp) + ])); + + _userService.GetUserByPrincipalAsync(context.Subject).Returns(user); + + await _sut.IsActiveAsync(context); + + Assert.True(context.IsActive); + } + + /// + /// Critical events (e.g., password change) update the security stamp, and any subsequent request through + /// this service should expose the stamp as invalid. + /// See also examples for stamp invalidation (non-exhaustive): + /// + /// + /// + /// + [Theory] + [BitAutoData(BitwardenClient.Web)] + [BitAutoData(BitwardenClient.Browser)] + [BitAutoData(BitwardenClient.Cli)] + [BitAutoData(BitwardenClient.Desktop)] + [BitAutoData(BitwardenClient.Mobile)] + [BitAutoData(BitwardenClient.DirectoryConnector)] + public async Task IsActiveAsync_SecurityStampDoesNotMatch_ReturnsFalse( + string client, + [AuthFixtures.IsActiveContext] IsActiveContext context, + User user) + { + context.Client.ClientId = client; + user.SecurityStamp = "current-security-stamp"; + + context.Subject = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim("sub", user.Id.ToString()), new Claim(Claims.SecurityStamp, "old-security-stamp") + ])); + + _userService.GetUserByPrincipalAsync(context.Subject).Returns(user); + + await _sut.IsActiveAsync(context); + + Assert.False(context.IsActive); + } + + /// + /// Because security stamps are GUIDs, and database collations, etc., might treat case differently, + /// a case-insensitive comparison is sufficient for proving the match of a security stamp. + /// + [Theory] + [BitAutoData(BitwardenClient.Web, "CuRrEnT-StAmP")] + [BitAutoData(BitwardenClient.Browser, "CuRrEnT-StAmP")] + [BitAutoData(BitwardenClient.Cli, "CuRrEnT-StAmP")] + [BitAutoData(BitwardenClient.Desktop, "CuRrEnT-StAmP")] + [BitAutoData(BitwardenClient.Mobile, "CuRrEnT-StAmP")] + [BitAutoData(BitwardenClient.DirectoryConnector, "CuRrEnT-StAmP")] + public async Task IsActiveAsync_SecurityStampComparison_IsCaseInsensitive( + string client, + string claimStamp, + [AuthFixtures.IsActiveContext] IsActiveContext context, + User user) + { + context.Client.ClientId = client; + user.SecurityStamp = "current-stamp"; + + context.Subject = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim("sub", user.Id.ToString()), new Claim(Claims.SecurityStamp, claimStamp) + ])); + + _userService.GetUserByPrincipalAsync(context.Subject).Returns(user); + + await _sut.IsActiveAsync(context); + + Assert.True(context.IsActive); + } + + /// + /// Security stamps should be evaluated when present, but should not always be expected to be present. + /// Given a successful user lookup, absent a security stamp, the session is treated as active. + /// Only if the stamp is presented on context claims should it be validated. + /// + [Theory] + [BitAutoData(BitwardenClient.Web)] + [BitAutoData(BitwardenClient.Browser)] + [BitAutoData(BitwardenClient.Cli)] + [BitAutoData(BitwardenClient.Desktop)] + [BitAutoData(BitwardenClient.Mobile)] + [BitAutoData(BitwardenClient.DirectoryConnector)] + public async Task IsActiveAsync_UserExistsButNoSecurityStampClaim_ReturnsTrue( + string client, + [AuthFixtures.IsActiveContext] IsActiveContext context, + User user) + { + context.Client.ClientId = client; + context.Subject = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim("sub", user.Id.ToString()), new Claim("email", user.Email) + ])); + + _userService.GetUserByPrincipalAsync(context.Subject).Returns(user); + + await _sut.IsActiveAsync(context); + + Assert.True(context.IsActive); + } +}