diff --git a/src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs b/src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs new file mode 100644 index 0000000000..c932eb0c34 --- /dev/null +++ b/src/Core/Auth/Sso/IUserSsoOrganizationIdentifierQuery.cs @@ -0,0 +1,23 @@ +using Bit.Core.Entities; + +namespace Bit.Core.Auth.Sso; + +/// +/// Query to retrieve the SSO organization identifier that a user is a confirmed member of. +/// +public interface IUserSsoOrganizationIdentifierQuery +{ + /// + /// Retrieves the SSO organization identifier for a confirmed organization user. + /// If there is more than one organization a User is associated with, we return null. If there are more than one + /// organization there is no way to know which organization the user wishes to authenticate with. + /// Owners and Admins who are not subject to the SSO required policy cannot utilize this flow, since they may have + /// multiple organizations with different SSO configurations. + /// + /// The ID of the to retrieve the SSO organization for. _Not_ an . + /// + /// The organization identifier if the user is a confirmed member of an organization with SSO configured, + /// otherwise null + /// + Task GetSsoOrganizationIdentifierAsync(Guid userId); +} diff --git a/src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs b/src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs new file mode 100644 index 0000000000..c0751e1f1a --- /dev/null +++ b/src/Core/Auth/Sso/UserSsoOrganizationIdentifierQuery.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Repositories; + +namespace Bit.Core.Auth.Sso; + +/// +/// TODO : PM-28846 review data structures as they relate to this query +/// Query to retrieve the SSO organization identifier that a user is a confirmed member of. +/// +public class UserSsoOrganizationIdentifierQuery( + IOrganizationUserRepository _organizationUserRepository, + IOrganizationRepository _organizationRepository) : IUserSsoOrganizationIdentifierQuery +{ + /// + public async Task GetSsoOrganizationIdentifierAsync(Guid userId) + { + // Get all confirmed organization memberships for the user + var organizationUsers = await _organizationUserRepository.GetManyByUserAsync(userId); + + // we can only confidently return the correct SsoOrganizationIdentifier if there is exactly one Organization. + // The user must also be in the Confirmed status. + var confirmedOrgUsers = organizationUsers.Where(ou => ou.Status == OrganizationUserStatusType.Confirmed); + if (confirmedOrgUsers.Count() != 1) + { + return null; + } + + var confirmedOrgUser = confirmedOrgUsers.Single(); + var organization = await _organizationRepository.GetByIdAsync(confirmedOrgUser.OrganizationId); + + if (organization == null) + { + return null; + } + + return organization.Identifier; + } +} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 53bd8bdba2..7c50f7f17b 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ - - +using Bit.Core.Auth.Sso; using Bit.Core.Auth.UserFeatures.DeviceTrust; using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration.Implementations; @@ -29,6 +28,7 @@ public static class UserServiceCollectionExtensions services.AddWebAuthnLoginCommands(); services.AddTdeOffboardingPasswordCommands(); services.AddTwoFactorQueries(); + services.AddSsoQueries(); } public static void AddDeviceTrustCommands(this IServiceCollection services) @@ -69,4 +69,9 @@ public static class UserServiceCollectionExtensions { services.AddScoped(); } + + private static void AddSsoQueries(this IServiceCollection services) + { + services.AddScoped(); + } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 5d2cd54489..781ec8b6c1 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -166,6 +166,7 @@ public static class FeatureFlagKeys public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email"; public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow"; + public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; diff --git a/src/Identity/IdentityServer/Constants/RequestValidationConstants.cs b/src/Identity/IdentityServer/Constants/RequestValidationConstants.cs new file mode 100644 index 0000000000..4787125045 --- /dev/null +++ b/src/Identity/IdentityServer/Constants/RequestValidationConstants.cs @@ -0,0 +1,30 @@ +namespace Bit.Identity.IdentityServer.RequestValidationConstants; + +public static class CustomResponseConstants +{ + public static class ResponseKeys + { + /// + /// Identifies the error model returned in the custom response when an error occurs. + /// + public static string ErrorModel => "ErrorModel"; + /// + /// This Key is used when a user is in a single organization that requires SSO authentication. The identifier + /// is used by the client to speed the redirection to the correct IdP for the user's organization. + /// + public static string SsoOrganizationIdentifier => "SsoOrganizationIdentifier"; + } +} + +public static class SsoConstants +{ + /// + /// These are messages and errors we return when SSO Validation is unsuccessful + /// + public static class RequestErrors + { + public static string SsoRequired => "sso_required"; + public static string SsoRequiredDescription => "Sso authentication is required."; + public static string SsoTwoFactorRecoveryDescription => "Two-factor recovery has been performed. SSO authentication is required."; + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 224c7a1866..fdc70b0edf 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -34,6 +34,7 @@ public abstract class BaseRequestValidator where T : class private readonly IEventService _eventService; private readonly IDeviceValidator _deviceValidator; private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator; + private readonly ISsoRequestValidator _ssoRequestValidator; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ILogger _logger; private readonly GlobalSettings _globalSettings; @@ -43,7 +44,7 @@ public abstract class BaseRequestValidator where T : class protected ICurrentContext CurrentContext { get; } protected IPolicyService PolicyService { get; } - protected IFeatureService FeatureService { get; } + protected IFeatureService _featureService { get; } protected ISsoConfigRepository SsoConfigRepository { get; } protected IUserService _userService { get; } protected IUserDecryptionOptionsBuilder UserDecryptionOptionsBuilder { get; } @@ -56,6 +57,7 @@ public abstract class BaseRequestValidator where T : class IEventService eventService, IDeviceValidator deviceValidator, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator, + ISsoRequestValidator ssoRequestValidator, IOrganizationUserRepository organizationUserRepository, ILogger logger, ICurrentContext currentContext, @@ -76,13 +78,14 @@ public abstract class BaseRequestValidator where T : class _eventService = eventService; _deviceValidator = deviceValidator; _twoFactorAuthenticationValidator = twoFactorAuthenticationValidator; + _ssoRequestValidator = ssoRequestValidator; _organizationUserRepository = organizationUserRepository; _logger = logger; CurrentContext = currentContext; _globalSettings = globalSettings; PolicyService = policyService; _userRepository = userRepository; - FeatureService = featureService; + _featureService = featureService; SsoConfigRepository = ssoConfigRepository; UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder; PolicyRequirementQuery = policyRequirementQuery; @@ -94,7 +97,7 @@ public abstract class BaseRequestValidator where T : class protected async Task ValidateAsync(T context, ValidatedTokenRequest request, CustomValidatorRequestContext validatorContext) { - if (FeatureService.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers)) + if (_featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers)) { var validators = DetermineValidationOrder(context, request, validatorContext); var allValidationSchemesSuccessful = await ProcessValidatorsAsync(validators); @@ -120,15 +123,29 @@ public abstract class BaseRequestValidator where T : class } // 2. Decide if this user belongs to an organization that requires SSO. - validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType); - if (validatorContext.SsoRequired) + // TODO: Clean up Feature Flag: Remove this if block: PM-28281 + if (!_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired)) { - SetSsoResult(context, - new Dictionary - { - { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } - }); - return; + validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType); + if (validatorContext.SsoRequired) + { + SetSsoResult(context, + new Dictionary + { + { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } + }); + return; + } + } + else + { + var ssoValid = await _ssoRequestValidator.ValidateAsync(user, request, validatorContext); + if (!ssoValid) + { + // SSO is required + SetValidationErrorResult(context, validatorContext); + return; + } } // 3. Check if 2FA is required. @@ -355,36 +372,51 @@ public abstract class BaseRequestValidator where T : class private async Task ValidateSsoAsync(T context, ValidatedTokenRequest request, CustomValidatorRequestContext validatorContext) { - validatorContext.SsoRequired = await RequireSsoLoginAsync(validatorContext.User, request.GrantType); - if (!validatorContext.SsoRequired) + // TODO: Clean up Feature Flag: Remove this if block: PM-28281 + if (!_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired)) { - return true; - } + validatorContext.SsoRequired = await RequireSsoLoginAsync(validatorContext.User, request.GrantType); + if (!validatorContext.SsoRequired) + { + return true; + } - // Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are - // presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and - // review their new recovery token if desired. - // SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery. - // As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been - // evaluated, and recovery will have been performed if requested. - // We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect - // to /login. - if (validatorContext.TwoFactorRequired && - validatorContext.TwoFactorRecoveryRequested) - { - SetSsoResult(context, new Dictionary + // Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are + // presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and + // review their new recovery token if desired. + // SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery. + // As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been + // evaluated, and recovery will have been performed if requested. + // We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect + // to /login. + if (validatorContext.TwoFactorRequired && + validatorContext.TwoFactorRecoveryRequested) + { + SetSsoResult(context, new Dictionary { { "ErrorModel", new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") } }); + return false; + } + + SetSsoResult(context, + new Dictionary + { + { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } + }); return false; } - - SetSsoResult(context, - new Dictionary + else + { + var ssoValid = await _ssoRequestValidator.ValidateAsync(validatorContext.User, request, validatorContext); + if (ssoValid) { - { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } - }); - return false; + return true; + } + + SetValidationErrorResult(context, validatorContext); + return ssoValid; + } } /// @@ -651,6 +683,7 @@ public abstract class BaseRequestValidator where T : class /// user trying to login /// magic string identifying the grant type requested /// true if sso required; false if not required or already in process + [Obsolete("This method is deprecated and will be removed in future versions, PM-28281. Please use the SsoRequestValidator scheme instead.")] private async Task RequireSsoLoginAsync(User user, string grantType) { if (grantType == "authorization_code" || grantType == "client_credentials") @@ -661,7 +694,7 @@ public abstract class BaseRequestValidator where T : class } // Check if user belongs to any organization with an active SSO policy - var ssoRequired = FeatureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) + var ssoRequired = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) ? (await PolicyRequirementQuery.GetAsync(user.Id)) .SsoRequired : await PolicyService.AnyPoliciesApplicableToUserAsync( @@ -703,7 +736,7 @@ public abstract class BaseRequestValidator where T : class private async Task SendFailedTwoFactorEmail(User user, TwoFactorProviderType failedAttemptType) { - if (FeatureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail)) + if (_featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail)) { await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow, CurrentContext.IpAddress); diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index 64156ea5f3..4d75da92fe 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -36,6 +36,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator logger, ICurrentContext currentContext, @@ -56,6 +57,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator +/// Validates whether a user is required to authenticate via SSO based on organization policies. +/// +public interface ISsoRequestValidator +{ + /// + /// Validates the SSO requirement for a user attempting to authenticate. Sets the error state in the if SSO is required. + /// + /// The user attempting to authenticate. + /// The token request containing grant type and other authentication details. + /// The validator context to be updated with SSO requirement status and error results if applicable. + /// true if the user can proceed with authentication; false if SSO is required and the user must be redirected to SSO flow. + Task ValidateAsync(User user, ValidatedTokenRequest request, CustomValidatorRequestContext context); +} diff --git a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs index d69d521ef7..ea2c021f63 100644 --- a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs @@ -31,6 +31,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator logger, ICurrentContext currentContext, @@ -50,6 +51,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator +/// Validates whether a user is required to authenticate via SSO based on organization policies. +/// +public class SsoRequestValidator( + IPolicyService _policyService, + IFeatureService _featureService, + IUserSsoOrganizationIdentifierQuery _userSsoOrganizationIdentifierQuery, + IPolicyRequirementQuery _policyRequirementQuery) : ISsoRequestValidator +{ + /// + /// Validates the SSO requirement for a user attempting to authenticate. + /// Sets context.SsoRequired to indicate whether SSO is required. + /// If SSO is required, sets the validation error result and custom response in the context. + /// + /// The user attempting to authenticate. + /// The token request containing grant type and other authentication details. + /// The validator context to be updated with SSO requirement status and error results if applicable. + /// true if the user can proceed with authentication; false if SSO is required and the user must be redirected to SSO flow. + public async Task ValidateAsync(User user, ValidatedTokenRequest request, CustomValidatorRequestContext context) + { + context.SsoRequired = await RequireSsoAuthenticationAsync(user, request.GrantType); + + if (!context.SsoRequired) + { + return true; + } + + // Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are + // presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and + // review their new recovery token if desired. + // SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery. + // As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been + // evaluated, and recovery will have been performed if requested. + // We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect + // to /login. + // If the feature flag RecoveryCodeSupportForSsoRequiredUsers is set to false then this code is unreachable since + // Two Factor validation occurs after SSO validation in that scenario. + if (context.TwoFactorRequired && context.TwoFactorRecoveryRequested) + { + await SetContextCustomResponseSsoErrorAsync(context, SsoConstants.RequestErrors.SsoTwoFactorRecoveryDescription); + return false; + } + + await SetContextCustomResponseSsoErrorAsync(context, SsoConstants.RequestErrors.SsoRequiredDescription); + return false; + } + + /// + /// Check if the user is required to authenticate via SSO. If the user requires SSO, but they are + /// logging in using an API Key (client_credentials) then they are allowed to bypass the SSO requirement. + /// If the GrantType is authorization_code or client_credentials we know the user is trying to login + /// using the SSO flow so they are allowed to continue. + /// + /// user trying to login + /// magic string identifying the grant type requested + /// true if sso required; false if not required or already in process + private async Task RequireSsoAuthenticationAsync(User user, string grantType) + { + if (grantType == OidcConstants.GrantTypes.AuthorizationCode || + grantType == OidcConstants.GrantTypes.ClientCredentials) + { + // SSO is not required for users already using SSO to authenticate which uses the authorization_code grant type, + // or logging-in via API key which is the client_credentials grant type. + // Allow user to continue request validation + return false; + } + + // Check if user belongs to any organization with an active SSO policy + var ssoRequired = _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) + ? (await _policyRequirementQuery.GetAsync(user.Id)) + .SsoRequired + : await _policyService.AnyPoliciesApplicableToUserAsync( + user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); + + if (ssoRequired) + { + return true; + } + + // Default - SSO is not required + return false; + } + + /// + /// Sets the customResponse in the context with the error result for the SSO validation failure. + /// + /// The validator context to update with error details. + /// The error message to return to the client. + private async Task SetContextCustomResponseSsoErrorAsync(CustomValidatorRequestContext context, string errorMessage) + { + var ssoOrganizationIdentifier = await _userSsoOrganizationIdentifierQuery.GetSsoOrganizationIdentifierAsync(context.User.Id); + + context.ValidationErrorResult = new ValidationResult + { + IsError = true, + Error = OidcConstants.TokenErrors.InvalidGrant, + ErrorDescription = errorMessage + }; + + context.CustomResponse = new Dictionary + { + { CustomResponseConstants.ResponseKeys.ErrorModel, new ErrorResponseModel(errorMessage) } + }; + + // Include organization identifier in the response if available + if (!string.IsNullOrEmpty(ssoOrganizationIdentifier)) + { + context.CustomResponse[CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier] = ssoOrganizationIdentifier; + } + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs index 294df1c18d..e4cd60827e 100644 --- a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs @@ -38,6 +38,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator logger, ICurrentContext currentContext, @@ -59,6 +60,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient, SendPasswordRequestValidator>(); services.AddTransient, SendEmailOtpRequestValidator>(); diff --git a/test/Core.Test/Auth/UserFeatures/Sso/UserSsoOrganizationIdentifierQueryTests.cs b/test/Core.Test/Auth/UserFeatures/Sso/UserSsoOrganizationIdentifierQueryTests.cs new file mode 100644 index 0000000000..2b448ba79f --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/Sso/UserSsoOrganizationIdentifierQueryTests.cs @@ -0,0 +1,275 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Sso; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.Sso; + +[SutProviderCustomize] +public class UserSsoOrganizationIdentifierQueryTests +{ + [Theory, BitAutoData] + public async Task GetSsoOrganizationIdentifierAsync_UserHasSingleConfirmedOrganization_ReturnsIdentifier( + SutProvider sutProvider, + Guid userId, + Organization organization, + OrganizationUser organizationUser) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + organizationUser.Status = OrganizationUserStatusType.Confirmed; + organization.Identifier = "test-org-identifier"; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns([organizationUser]); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Equal("test-org-identifier", result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organization.Id); + } + + [Theory, BitAutoData] + public async Task GetSsoOrganizationIdentifierAsync_UserHasNoOrganizations_ReturnsNull( + SutProvider sutProvider, + Guid userId) + { + // Arrange + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(Array.Empty()); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Null(result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .DidNotReceive() + .GetByIdAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetSsoOrganizationIdentifierAsync_UserHasMultipleConfirmedOrganizations_ReturnsNull( + SutProvider sutProvider, + Guid userId, + OrganizationUser organizationUser1, + OrganizationUser organizationUser2) + { + // Arrange + organizationUser1.UserId = userId; + organizationUser1.Status = OrganizationUserStatusType.Confirmed; + organizationUser2.UserId = userId; + organizationUser2.Status = OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns([organizationUser1, organizationUser2]); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Null(result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .DidNotReceive() + .GetByIdAsync(Arg.Any()); + } + + [Theory] + [BitAutoData(OrganizationUserStatusType.Invited)] + [BitAutoData(OrganizationUserStatusType.Accepted)] + [BitAutoData(OrganizationUserStatusType.Revoked)] + public async Task GetSsoOrganizationIdentifierAsync_UserHasOnlyInvitedOrganization_ReturnsNull( + OrganizationUserStatusType status, + SutProvider sutProvider, + Guid userId, + OrganizationUser organizationUser) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.Status = status; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns([organizationUser]); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Null(result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .DidNotReceive() + .GetByIdAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetSsoOrganizationIdentifierAsync_UserHasMixedStatusOrganizations_OnlyOneConfirmed_ReturnsIdentifier( + SutProvider sutProvider, + Guid userId, + Organization organization, + OrganizationUser confirmedOrgUser, + OrganizationUser invitedOrgUser, + OrganizationUser revokedOrgUser) + { + // Arrange + confirmedOrgUser.UserId = userId; + confirmedOrgUser.OrganizationId = organization.Id; + confirmedOrgUser.Status = OrganizationUserStatusType.Confirmed; + + invitedOrgUser.UserId = userId; + invitedOrgUser.Status = OrganizationUserStatusType.Invited; + + revokedOrgUser.UserId = userId; + revokedOrgUser.Status = OrganizationUserStatusType.Revoked; + + organization.Identifier = "mixed-status-org"; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(new[] { confirmedOrgUser, invitedOrgUser, revokedOrgUser }); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Equal("mixed-status-org", result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organization.Id); + } + + [Theory, BitAutoData] + public async Task GetSsoOrganizationIdentifierAsync_OrganizationNotFound_ReturnsNull( + SutProvider sutProvider, + Guid userId, + OrganizationUser organizationUser) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.Status = OrganizationUserStatusType.Confirmed; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns([organizationUser]); + + sutProvider.GetDependency() + .GetByIdAsync(organizationUser.OrganizationId) + .Returns((Organization)null); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Null(result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organizationUser.OrganizationId); + } + + [Theory, BitAutoData] + public async Task GetSsoOrganizationIdentifierAsync_OrganizationIdentifierIsNull_ReturnsNull( + SutProvider sutProvider, + Guid userId, + Organization organization, + OrganizationUser organizationUser) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + organizationUser.Status = OrganizationUserStatusType.Confirmed; + organization.Identifier = null; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(new[] { organizationUser }); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Null(result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organization.Id); + } + + [Theory, BitAutoData] + public async Task GetSsoOrganizationIdentifierAsync_OrganizationIdentifierIsEmpty_ReturnsEmpty( + SutProvider sutProvider, + Guid userId, + Organization organization, + OrganizationUser organizationUser) + { + // Arrange + organizationUser.UserId = userId; + organizationUser.OrganizationId = organization.Id; + organizationUser.Status = OrganizationUserStatusType.Confirmed; + organization.Identifier = string.Empty; + + sutProvider.GetDependency() + .GetManyByUserAsync(userId) + .Returns(new[] { organizationUser }); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + // Act + var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId); + + // Assert + Assert.Equal(string.Empty, result); + await sutProvider.GetDependency() + .Received(1) + .GetManyByUserAsync(userId); + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organization.Id); + } +} diff --git a/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs b/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs index 3063524a57..9dfdf723f3 100644 --- a/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs +++ b/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs @@ -44,14 +44,17 @@ internal class CustomValidatorRequestContextCustomization : ICustomization /// , and /// should initialize false, /// and are made truthy in context upon evaluation of a request. Do not allow AutoFixture to eagerly make these - /// truthy; that is the responsibility of the + /// truthy; that is the responsibility of the . + /// ValidationErrorResult and CustomResponse should also be null initially; they are hydrated during the validation process. /// public void Customize(IFixture fixture) { fixture.Customize(composer => composer .With(o => o.RememberMeRequested, false) .With(o => o.TwoFactorRecoveryRequested, false) - .With(o => o.SsoRequired, false)); + .With(o => o.SsoRequired, false) + .With(o => o.ValidationErrorResult, () => null) + .With(o => o.CustomResponse, () => null)); } } diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs index e78c7d161c..214fa74ff4 100644 --- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs @@ -21,6 +21,7 @@ using Bit.Identity.IdentityServer; using Bit.Identity.IdentityServer.RequestValidators; using Bit.Identity.Test.Wrappers; using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityModel; using Duende.IdentityServer.Validation; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; @@ -42,6 +43,7 @@ public class BaseRequestValidatorTests private readonly IEventService _eventService; private readonly IDeviceValidator _deviceValidator; private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator; + private readonly ISsoRequestValidator _ssoRequestValidator; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly FakeLogger _logger; private readonly ICurrentContext _currentContext; @@ -65,6 +67,7 @@ public class BaseRequestValidatorTests _eventService = Substitute.For(); _deviceValidator = Substitute.For(); _twoFactorAuthenticationValidator = Substitute.For(); + _ssoRequestValidator = Substitute.For(); _organizationUserRepository = Substitute.For(); _logger = new FakeLogger(); _currentContext = Substitute.For(); @@ -85,6 +88,7 @@ public class BaseRequestValidatorTests _eventService, _deviceValidator, _twoFactorAuthenticationValidator, + _ssoRequestValidator, _organizationUserRepository, _logger, _currentContext, @@ -151,6 +155,7 @@ public class BaseRequestValidatorTests // Arrange SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); + // 1 -> to pass _sut.isValid = true; @@ -162,9 +167,9 @@ public class BaseRequestValidatorTests // 4 -> set up device validator to fail requestContext.KnownDevice = false; - tokenRequest.GrantType = "password"; + tokenRequest.GrantType = OidcConstants.GrantTypes.Password; _deviceValidator - .ValidateRequestDeviceAsync(Arg.Any(), Arg.Any()) + .ValidateRequestDeviceAsync(tokenRequest, requestContext) .Returns(Task.FromResult(false)); // 5 -> not legacy user @@ -192,6 +197,7 @@ public class BaseRequestValidatorTests // Arrange SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); + // 1 -> to pass _sut.isValid = true; @@ -203,12 +209,13 @@ public class BaseRequestValidatorTests // 4 -> set up device validator to pass _deviceValidator - .ValidateRequestDeviceAsync(Arg.Any(), Arg.Any()) + .ValidateRequestDeviceAsync(tokenRequest, requestContext) .Returns(Task.FromResult(true)); // 5 -> not legacy user _userService.IsLegacyUser(Arg.Any()) .Returns(false); + _userAccountKeysQuery.Run(Arg.Any()).Returns(new UserAccountKeysData { PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( @@ -236,6 +243,7 @@ public class BaseRequestValidatorTests // Arrange SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); + // 1 -> to pass _sut.isValid = true; @@ -262,12 +270,13 @@ public class BaseRequestValidatorTests // 4 -> set up device validator to pass _deviceValidator - .ValidateRequestDeviceAsync(Arg.Any(), Arg.Any()) + .ValidateRequestDeviceAsync(tokenRequest, requestContext) .Returns(Task.FromResult(true)); // 5 -> not legacy user _userService.IsLegacyUser(Arg.Any()) .Returns(false); + _userAccountKeysQuery.Run(Arg.Any()).Returns(new UserAccountKeysData { PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( @@ -299,6 +308,7 @@ public class BaseRequestValidatorTests // Arrange SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); + // 1 -> to pass _sut.isValid = true; @@ -319,10 +329,19 @@ public class BaseRequestValidatorTests // 2 -> will result to false with no extra configuration // 3 -> set two factor to be required + requestContext.User.TwoFactorProviders = "{\"1\":{\"Enabled\":true,\"MetaData\":{\"Email\":\"user@test.dev\"}}}"; _twoFactorAuthenticationValidator - .RequiresTwoFactorAsync(Arg.Any(), tokenRequest) + .RequiresTwoFactorAsync(requestContext.User, tokenRequest) .Returns(Task.FromResult(new Tuple(true, null))); + _twoFactorAuthenticationValidator + .BuildTwoFactorResultAsync(requestContext.User, null) + .Returns(Task.FromResult(new Dictionary + { + { "TwoFactorProviders", new[] { "0", "1" } }, + { "TwoFactorProviders2", new Dictionary{{"Email", null}} } + })); + // Act await _sut.ValidateAsync(context); @@ -330,7 +349,10 @@ public class BaseRequestValidatorTests Assert.True(context.GrantResult.IsError); // Assert that the auth request was NOT consumed - await _authRequestRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + await _authRequestRepository.DidNotReceive().ReplaceAsync(authRequest); + + // Assert that the error is for 2fa + Assert.Equal("Two-factor authentication required.", context.GrantResult.ErrorDescription); } [Theory] @@ -420,6 +442,7 @@ public class BaseRequestValidatorTests { "TwoFactorProviders", new[] { "0", "1" } }, { "TwoFactorProviders2", new Dictionary() } }; + _twoFactorAuthenticationValidator .BuildTwoFactorResultAsync(user, null) .Returns(Task.FromResult(twoFactorResultDict)); @@ -428,6 +451,8 @@ public class BaseRequestValidatorTests await _sut.ValidateAsync(context); // Assert + Assert.Equal("Two-factor authentication required.", context.GrantResult.ErrorDescription); + // Verify that the failed 2FA email was NOT sent for remember token expiration await _mailService.DidNotReceive() .SendFailedTwoFactorAttemptEmailAsync(Arg.Any(), Arg.Any(), @@ -1243,6 +1268,343 @@ public class BaseRequestValidatorTests } } + /// + /// Tests that when RedirectOnSsoRequired is DISABLED, the legacy SSO validation path is used. + /// This validates the deprecated RequireSsoLoginAsync method is called and SSO requirement + /// is checked using the old PolicyService.AnyPoliciesApplicableToUserAsync approach. + /// + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ValidateAsync_RedirectOnSsoRequired_Disabled_UsesLegacySsoValidation( + bool recoveryCodeFeatureEnabled, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled); + _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(false); + + var context = CreateContext(tokenRequest, requestContext, grantResult); + _sut.isValid = true; + + tokenRequest.GrantType = OidcConstants.GrantTypes.Password; + + // SSO is required via legacy path + _policyService.AnyPoliciesApplicableToUserAsync( + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + .Returns(Task.FromResult(true)); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.True(context.GrantResult.IsError); + var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; + Assert.Equal("SSO authentication is required.", errorResponse.Message); + + // Verify legacy path was used + await _policyService.Received(1).AnyPoliciesApplicableToUserAsync( + requestContext.User.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); + + // Verify new SsoRequestValidator was NOT called + await _ssoRequestValidator.DidNotReceive().ValidateAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + /// + /// Tests that when RedirectOnSsoRequired is ENABLED, the new ISsoRequestValidator is used + /// instead of the legacy RequireSsoLoginAsync method. + /// + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_UsesNewSsoRequestValidator( + bool recoveryCodeFeatureEnabled, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled); + _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); + + var context = CreateContext(tokenRequest, requestContext, grantResult); + _sut.isValid = true; + + tokenRequest.GrantType = OidcConstants.GrantTypes.Password; + + // Configure SsoRequestValidator to indicate SSO is required + _ssoRequestValidator.ValidateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(false)); // false = SSO required + + // Set up the ValidationErrorResult that SsoRequestValidator would set + requestContext.ValidationErrorResult = new ValidationResult + { + IsError = true, + Error = "sso_required", + ErrorDescription = "SSO authentication is required." + }; + requestContext.CustomResponse = new Dictionary + { + { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } + }; + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.True(context.GrantResult.IsError); + + // Verify new SsoRequestValidator was called + await _ssoRequestValidator.Received(1).ValidateAsync( + requestContext.User, + tokenRequest, + requestContext); + + // Verify legacy path was NOT used + await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + + /// + /// Tests that when RedirectOnSsoRequired is ENABLED and SSO is NOT required, + /// authentication continues successfully through the new validation path. + /// + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_SsoNotRequired_SuccessfulLogin( + bool recoveryCodeFeatureEnabled, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled); + _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); + + var context = CreateContext(tokenRequest, requestContext, grantResult); + _sut.isValid = true; + + tokenRequest.GrantType = OidcConstants.GrantTypes.Password; + tokenRequest.ClientId = "web"; + + // SsoRequestValidator returns true (SSO not required) + _ssoRequestValidator.ValidateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(true)); + + // No 2FA required + _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest) + .Returns(Task.FromResult(new Tuple(false, null))); + + // Device validation passes + _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + + // User is not legacy + _userService.IsLegacyUser(Arg.Any()).Returns(false); + + _userAccountKeysQuery.Run(Arg.Any()).Returns(new UserAccountKeysData + { + PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( + "test-private-key", + "test-public-key" + ) + }); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.False(context.GrantResult.IsError); + await _eventService.Received(1).LogUserEventAsync(requestContext.User.Id, EventType.User_LoggedIn); + + // Verify new validator was used + await _ssoRequestValidator.Received(1).ValidateAsync( + requestContext.User, + tokenRequest, + requestContext); + } + + /// + /// Tests that when RedirectOnSsoRequired is ENABLED and SSO validation returns a custom response + /// (e.g., with organization identifier), that custom response is properly propagated to the result. + /// + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_PropagatesCustomResponse( + bool recoveryCodeFeatureEnabled, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(recoveryCodeFeatureEnabled); + _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); + _sut.isValid = true; + + tokenRequest.GrantType = OidcConstants.GrantTypes.Password; + + // SsoRequestValidator sets custom response with organization identifier + requestContext.ValidationErrorResult = new ValidationResult + { + IsError = true, + Error = "sso_required", + ErrorDescription = "SSO authentication is required." + }; + requestContext.CustomResponse = new Dictionary + { + { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }, + { "SsoOrganizationIdentifier", "test-org-identifier" } + }; + + var context = CreateContext(tokenRequest, requestContext, grantResult); + + _ssoRequestValidator.ValidateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(false)); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.True(context.GrantResult.IsError); + Assert.NotNull(context.GrantResult.CustomResponse); + Assert.Contains("SsoOrganizationIdentifier", context.CustomValidatorRequestContext.CustomResponse); + Assert.Equal("test-org-identifier", context.CustomValidatorRequestContext.CustomResponse["SsoOrganizationIdentifier"]); + } + + /// + /// Tests that when RedirectOnSsoRequired is DISABLED and a user with 2FA recovery completes recovery, + /// but SSO is required, the legacy error message is returned (without the recovery-specific message). + /// + [Theory] + [BitAutoData] + public async Task ValidateAsync_RedirectOnSsoRequired_Disabled_RecoveryWithSso_LegacyMessage( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(true); + _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(false); + + var context = CreateContext(tokenRequest, requestContext, grantResult); + _sut.isValid = true; + + // Recovery code scenario + tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString(); + tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code"; + + // 2FA with recovery + _twoFactorAuthenticationValidator + .RequiresTwoFactorAsync(requestContext.User, tokenRequest) + .Returns(Task.FromResult(new Tuple(true, null))); + + _twoFactorAuthenticationValidator + .VerifyTwoFactorAsync(requestContext.User, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code") + .Returns(Task.FromResult(true)); + + // SSO is required (legacy check) + _policyService.AnyPoliciesApplicableToUserAsync( + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + .Returns(Task.FromResult(true)); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.True(context.GrantResult.IsError); + var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; + + // Legacy behavior: recovery-specific message IS shown even without RedirectOnSsoRequired + Assert.Equal("Two-factor recovery has been performed. SSO authentication is required.", errorResponse.Message); + + // But legacy validation path was used + await _policyService.Received(1).AnyPoliciesApplicableToUserAsync( + requestContext.User.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); + } + + /// + /// Tests that when RedirectOnSsoRequired is ENABLED and recovery code is used for SSO-required user, + /// the SsoRequestValidator provides the recovery-specific error message. + /// + [Theory] + [BitAutoData] + public async Task ValidateAsync_RedirectOnSsoRequired_Enabled_RecoveryWithSso_NewValidatorMessage( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(true); + _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(true); + + var context = CreateContext(tokenRequest, requestContext, grantResult); + _sut.isValid = true; + + // Recovery code scenario + tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString(); + tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code"; + + // 2FA with recovery + _twoFactorAuthenticationValidator + .RequiresTwoFactorAsync(requestContext.User, tokenRequest) + .Returns(Task.FromResult(new Tuple(true, null))); + + _twoFactorAuthenticationValidator + .VerifyTwoFactorAsync(requestContext.User, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code") + .Returns(Task.FromResult(true)); + + // SsoRequestValidator handles the recovery + SSO scenario + requestContext.TwoFactorRecoveryRequested = true; + requestContext.ValidationErrorResult = new ValidationResult + { + IsError = true, + Error = "sso_required", + ErrorDescription = "Two-factor recovery has been performed. SSO authentication is required." + }; + requestContext.CustomResponse = new Dictionary + { + { "ErrorModel", new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") } + }; + + _ssoRequestValidator.ValidateAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(false)); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.True(context.GrantResult.IsError); + var errorResponse = (ErrorResponseModel)context.CustomValidatorRequestContext.CustomResponse["ErrorModel"]; + Assert.Equal("Two-factor recovery has been performed. SSO authentication is required.", errorResponse.Message); + + // Verify new validator was used + await _ssoRequestValidator.Received(1).ValidateAsync( + requestContext.User, + tokenRequest, + Arg.Is(ctx => ctx.TwoFactorRecoveryRequested)); + + // Verify legacy path was NOT used + await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + } + private BaseRequestValidationContextFake CreateContext( ValidatedTokenRequest tokenRequest, CustomValidatorRequestContext requestContext, diff --git a/test/Identity.Test/IdentityServer/SsoRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SsoRequestValidatorTests.cs new file mode 100644 index 0000000000..2875b5bd37 --- /dev/null +++ b/test/Identity.Test/IdentityServer/SsoRequestValidatorTests.cs @@ -0,0 +1,469 @@ +using Bit.Core; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.Sso; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Services; +using Bit.Identity.IdentityServer; +using Bit.Identity.IdentityServer.Enums; +using Bit.Identity.IdentityServer.RequestValidationConstants; +using Bit.Identity.IdentityServer.RequestValidators; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityModel; +using Duende.IdentityServer.Validation; +using NSubstitute; +using Xunit; +using AuthFixtures = Bit.Identity.Test.AutoFixture; + +namespace Bit.Identity.Test.IdentityServer; + +[SutProviderCustomize] +public class SsoRequestValidatorTests +{ + + [Theory] + [BitAutoData(OidcConstants.GrantTypes.AuthorizationCode)] + [BitAutoData(OidcConstants.GrantTypes.ClientCredentials)] + public async void ValidateAsync_GrantTypeIgnoresSsoRequirement_ReturnsTrue( + string grantType, + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = grantType; + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.True(result); + Assert.False(context.SsoRequired); + Assert.Null(context.ValidationErrorResult); + Assert.Null(context.CustomResponse); + + // Should not check policies since grant type allows bypass + await sutProvider.GetDependency().DidNotReceive() + .AnyPoliciesApplicableToUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await sutProvider.GetDependency().DidNotReceive() + .GetAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoNotRequired_RequirementPolicyFeatureFlagEnabled_ReturnsTrue( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = false }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.True(result); + Assert.False(context.SsoRequired); + Assert.Null(context.ValidationErrorResult); + Assert.Null(context.CustomResponse); + + // Should use the new policy requirement query when feature flag is enabled + await sutProvider.GetDependency().Received(1).GetAsync(user.Id); + await sutProvider.GetDependency().DidNotReceive() + .AnyPoliciesApplicableToUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoNotRequired_RequirementPolicyFeatureFlagDisabled_ReturnsTrue( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false); + + sutProvider.GetDependency().AnyPoliciesApplicableToUserAsync( + user.Id, + PolicyType.RequireSso, + OrganizationUserStatusType.Confirmed) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.True(result); + Assert.False(context.SsoRequired); + Assert.Null(context.ValidationErrorResult); + Assert.Null(context.CustomResponse); + + // Should use the legacy policy service when feature flag is disabled + await sutProvider.GetDependency().Received(1).AnyPoliciesApplicableToUserAsync( + user.Id, + PolicyType.RequireSso, + OrganizationUserStatusType.Confirmed); + await sutProvider.GetDependency().DidNotReceive() + .GetAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoRequired_RequirementPolicyFeatureFlagEnabled_ReturnsFalse( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns((string)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.ValidationErrorResult); + Assert.True(context.ValidationErrorResult.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error); + Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, context.ValidationErrorResult.ErrorDescription); + + Assert.NotNull(context.CustomResponse); + Assert.True(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.ErrorModel)); + Assert.False(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier)); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoRequired_RequirementPolicyFeatureFlagDisabled_ReturnsFalse( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false); + + sutProvider.GetDependency().AnyPoliciesApplicableToUserAsync( + user.Id, + PolicyType.RequireSso, + OrganizationUserStatusType.Confirmed) + .Returns(true); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns((string)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.ValidationErrorResult); + Assert.True(context.ValidationErrorResult.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error); + Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, context.ValidationErrorResult.ErrorDescription); + + Assert.NotNull(context.CustomResponse); + Assert.True(context.CustomResponse.ContainsKey("ErrorModel")); + Assert.False(context.CustomResponse.ContainsKey("SsoOrganizationIdentifier")); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoRequired_TwoFactorRecoveryRequested_ReturnsFalse_WithSpecialMessage( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + context.TwoFactorRecoveryRequested = true; + context.TwoFactorRequired = true; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns((string)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.ValidationErrorResult); + Assert.True(context.ValidationErrorResult.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error); + Assert.Equal("Two-factor recovery has been performed. SSO authentication is required.", + context.ValidationErrorResult.ErrorDescription); + + Assert.NotNull(context.CustomResponse); + Assert.True(context.CustomResponse.ContainsKey("ErrorModel")); + Assert.False(context.CustomResponse.ContainsKey("SsoOrganizationIdentifier")); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoRequired_TwoFactorRequiredButNotRecovery_ReturnsFalse_WithStandardMessage( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns((string)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.ValidationErrorResult); + Assert.True(context.ValidationErrorResult.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error); + Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, context.ValidationErrorResult.ErrorDescription); + + Assert.NotNull(context.CustomResponse); + Assert.True(context.CustomResponse.ContainsKey("ErrorModel")); + Assert.False(context.CustomResponse.ContainsKey("SsoOrganizationIdentifier")); + } + + [Theory] + [BitAutoData(OidcConstants.GrantTypes.Password)] + [BitAutoData(OidcConstants.GrantTypes.RefreshToken)] + [BitAutoData(CustomGrantTypes.WebAuthn)] + public async void ValidateAsync_VariousGrantTypes_SsoRequired_ReturnsFalse( + string grantType, + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = grantType; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns((string)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.ValidationErrorResult); + Assert.True(context.ValidationErrorResult.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.ValidationErrorResult.Error); + Assert.Equal(SsoConstants.RequestErrors.SsoRequiredDescription, context.ValidationErrorResult.ErrorDescription); + Assert.NotNull(context.CustomResponse); + } + + [Theory, BitAutoData] + public async void ValidateAsync_ContextSsoRequiredUpdated_RegardlessOfInitialValue( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + context.SsoRequired = true; // Start with true to ensure it gets updated + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = false }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.True(result); + Assert.False(context.SsoRequired); // Should be updated to false + Assert.Null(context.ValidationErrorResult); + Assert.Null(context.CustomResponse); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoRequired_WithOrganizationIdentifier_IncludesIdentifierInResponse( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + const string orgIdentifier = "test-organization"; + request.GrantType = OidcConstants.GrantTypes.Password; + context.User = user; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns(orgIdentifier); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.CustomResponse); + Assert.True(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier)); + Assert.Equal(orgIdentifier, context.CustomResponse[CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier]); + + await sutProvider.GetDependency() + .Received(1) + .GetSsoOrganizationIdentifierAsync(user.Id); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoRequired_NoOrganizationIdentifier_DoesNotIncludeIdentifierInResponse( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + context.User = user; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns((string)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.CustomResponse); + Assert.False(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier)); + + await sutProvider.GetDependency() + .Received(1) + .GetSsoOrganizationIdentifierAsync(user.Id); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoRequired_EmptyOrganizationIdentifier_DoesNotIncludeIdentifierInResponse( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + context.User = user; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = true }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + sutProvider.GetDependency() + .GetSsoOrganizationIdentifierAsync(user.Id) + .Returns(string.Empty); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.False(result); + Assert.True(context.SsoRequired); + Assert.NotNull(context.CustomResponse); + Assert.False(context.CustomResponse.ContainsKey(CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier)); + + await sutProvider.GetDependency() + .Received(1) + .GetSsoOrganizationIdentifierAsync(user.Id); + } + + [Theory, BitAutoData] + public async void ValidateAsync_SsoNotRequired_DoesNotCallOrganizationIdentifierQuery( + User user, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request, + SutProvider sutProvider) + { + // Arrange + request.GrantType = OidcConstants.GrantTypes.Password; + + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + var requirement = new RequireSsoPolicyRequirement { SsoRequired = false }; + sutProvider.GetDependency().GetAsync(user.Id) + .Returns(requirement); + + // Act + var result = await sutProvider.Sut.ValidateAsync(user, request, context); + + // Assert + Assert.True(result); + Assert.False(context.SsoRequired); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetSsoOrganizationIdentifierAsync(Arg.Any()); + } +} diff --git a/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs b/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs index 53e9a00c9f..c4cbd4b796 100644 --- a/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs @@ -32,7 +32,7 @@ public class TwoFactorAuthenticationValidatorTests private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IDataProtectorTokenFactory _ssoEmail2faSessionTokenable; - private readonly ITwoFactorIsEnabledQuery _twoFactorenabledQuery; + private readonly ITwoFactorIsEnabledQuery _twoFactorEnabledQuery; private readonly ICurrentContext _currentContext; private readonly TwoFactorAuthenticationValidator _sut; @@ -45,7 +45,7 @@ public class TwoFactorAuthenticationValidatorTests _organizationUserRepository = Substitute.For(); _organizationRepository = Substitute.For(); _ssoEmail2faSessionTokenable = Substitute.For>(); - _twoFactorenabledQuery = Substitute.For(); + _twoFactorEnabledQuery = Substitute.For(); _currentContext = Substitute.For(); _sut = new TwoFactorAuthenticationValidator( @@ -56,7 +56,7 @@ public class TwoFactorAuthenticationValidatorTests _organizationUserRepository, _organizationRepository, _ssoEmail2faSessionTokenable, - _twoFactorenabledQuery, + _twoFactorEnabledQuery, _currentContext); } diff --git a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs index ec3e791d5b..b336e4c3c1 100644 --- a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs +++ b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs @@ -54,6 +54,7 @@ IBaseRequestValidatorTestWrapper IEventService eventService, IDeviceValidator deviceValidator, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator, + ISsoRequestValidator ssoRequestValidator, IOrganizationUserRepository organizationUserRepository, ILogger logger, ICurrentContext currentContext, @@ -73,6 +74,7 @@ IBaseRequestValidatorTestWrapper eventService, deviceValidator, twoFactorAuthenticationValidator, + ssoRequestValidator, organizationUserRepository, logger, currentContext, @@ -132,12 +134,17 @@ IBaseRequestValidatorTestWrapper protected override void SetTwoFactorResult( BaseRequestValidationContextFake context, Dictionary customResponse) - { } + { + context.GrantResult = new GrantValidationResult( + TokenRequestErrors.InvalidGrant, "Two-factor authentication required.", customResponse); + } protected override void SetValidationErrorResult( BaseRequestValidationContextFake context, CustomValidatorRequestContext requestContext) - { } + { + context.GrantResult.IsError = true; + } protected override Task ValidateContextAsync( BaseRequestValidationContextFake context,