1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

[PM-1632] Redirect on SsoRequired - return SsoOrganizationIdentifier (#6597)

feat: add SSO request validation and organization identifier lookup

- Implement SsoRequestValidator to validate SSO requirements
- Add UserSsoOrganizationIdentifierQuery to fetch organization identifiers
- Create SsoOrganizationIdentifier custom response for SSO redirects
- Add feature flag (RedirectOnSsoRequired) for gradual rollout
- Register validators and queries in dependency injection
- Create RequestValidationConstants to reduce magic strings
- Add comprehensive test coverage for validation logic
- Update BaseRequestValidator to consume SsoRequestValidator
This commit is contained in:
Ike
2025-11-30 16:55:47 -05:00
committed by GitHub
parent f151abee54
commit 8a67aafbe5
18 changed files with 1448 additions and 50 deletions

View File

@@ -0,0 +1,23 @@
using Bit.Core.Entities;
namespace Bit.Core.Auth.Sso;
/// <summary>
/// Query to retrieve the SSO organization identifier that a user is a confirmed member of.
/// </summary>
public interface IUserSsoOrganizationIdentifierQuery
{
/// <summary>
/// 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.
/// </summary>
/// <param name="userId">The ID of the <see cref="User"/> to retrieve the SSO organization for. _Not_ an <see cref="OrganizationUser"/>.</param>
/// <returns>
/// The organization identifier if the user is a confirmed member of an organization with SSO configured,
/// otherwise null
/// </returns>
Task<string?> GetSsoOrganizationIdentifierAsync(Guid userId);
}

View File

@@ -0,0 +1,38 @@
using Bit.Core.Enums;
using Bit.Core.Repositories;
namespace Bit.Core.Auth.Sso;
/// <summary>
/// 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.
/// </summary>
public class UserSsoOrganizationIdentifierQuery(
IOrganizationUserRepository _organizationUserRepository,
IOrganizationRepository _organizationRepository) : IUserSsoOrganizationIdentifierQuery
{
/// <inheritdoc />
public async Task<string?> 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;
}
}

View File

@@ -1,5 +1,4 @@
 using Bit.Core.Auth.Sso;
using Bit.Core.Auth.UserFeatures.DeviceTrust; using Bit.Core.Auth.UserFeatures.DeviceTrust;
using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration;
using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Auth.UserFeatures.Registration.Implementations;
@@ -29,6 +28,7 @@ public static class UserServiceCollectionExtensions
services.AddWebAuthnLoginCommands(); services.AddWebAuthnLoginCommands();
services.AddTdeOffboardingPasswordCommands(); services.AddTdeOffboardingPasswordCommands();
services.AddTwoFactorQueries(); services.AddTwoFactorQueries();
services.AddSsoQueries();
} }
public static void AddDeviceTrustCommands(this IServiceCollection services) public static void AddDeviceTrustCommands(this IServiceCollection services)
@@ -69,4 +69,9 @@ public static class UserServiceCollectionExtensions
{ {
services.AddScoped<ITwoFactorIsEnabledQuery, TwoFactorIsEnabledQuery>(); services.AddScoped<ITwoFactorIsEnabledQuery, TwoFactorIsEnabledQuery>();
} }
private static void AddSsoQueries(this IServiceCollection services)
{
services.AddScoped<IUserSsoOrganizationIdentifierQuery, UserSsoOrganizationIdentifierQuery>();
}
} }

View File

@@ -166,6 +166,7 @@ public static class FeatureFlagKeys
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email"; public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email";
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow"; public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";
public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required";
/* Autofill Team */ /* Autofill Team */
public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; public const string IdpAutoSubmitLogin = "idp-auto-submit-login";

View File

@@ -0,0 +1,30 @@
namespace Bit.Identity.IdentityServer.RequestValidationConstants;
public static class CustomResponseConstants
{
public static class ResponseKeys
{
/// <summary>
/// Identifies the error model returned in the custom response when an error occurs.
/// </summary>
public static string ErrorModel => "ErrorModel";
/// <summary>
/// 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.
/// </summary>
public static string SsoOrganizationIdentifier => "SsoOrganizationIdentifier";
}
}
public static class SsoConstants
{
/// <summary>
/// These are messages and errors we return when SSO Validation is unsuccessful
/// </summary>
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.";
}
}

View File

@@ -34,6 +34,7 @@ public abstract class BaseRequestValidator<T> where T : class
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly IDeviceValidator _deviceValidator; private readonly IDeviceValidator _deviceValidator;
private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator; private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator;
private readonly ISsoRequestValidator _ssoRequestValidator;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
@@ -43,7 +44,7 @@ public abstract class BaseRequestValidator<T> where T : class
protected ICurrentContext CurrentContext { get; } protected ICurrentContext CurrentContext { get; }
protected IPolicyService PolicyService { get; } protected IPolicyService PolicyService { get; }
protected IFeatureService FeatureService { get; } protected IFeatureService _featureService { get; }
protected ISsoConfigRepository SsoConfigRepository { get; } protected ISsoConfigRepository SsoConfigRepository { get; }
protected IUserService _userService { get; } protected IUserService _userService { get; }
protected IUserDecryptionOptionsBuilder UserDecryptionOptionsBuilder { get; } protected IUserDecryptionOptionsBuilder UserDecryptionOptionsBuilder { get; }
@@ -56,6 +57,7 @@ public abstract class BaseRequestValidator<T> where T : class
IEventService eventService, IEventService eventService,
IDeviceValidator deviceValidator, IDeviceValidator deviceValidator,
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
ISsoRequestValidator ssoRequestValidator,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
ILogger logger, ILogger logger,
ICurrentContext currentContext, ICurrentContext currentContext,
@@ -76,13 +78,14 @@ public abstract class BaseRequestValidator<T> where T : class
_eventService = eventService; _eventService = eventService;
_deviceValidator = deviceValidator; _deviceValidator = deviceValidator;
_twoFactorAuthenticationValidator = twoFactorAuthenticationValidator; _twoFactorAuthenticationValidator = twoFactorAuthenticationValidator;
_ssoRequestValidator = ssoRequestValidator;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_logger = logger; _logger = logger;
CurrentContext = currentContext; CurrentContext = currentContext;
_globalSettings = globalSettings; _globalSettings = globalSettings;
PolicyService = policyService; PolicyService = policyService;
_userRepository = userRepository; _userRepository = userRepository;
FeatureService = featureService; _featureService = featureService;
SsoConfigRepository = ssoConfigRepository; SsoConfigRepository = ssoConfigRepository;
UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder; UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder;
PolicyRequirementQuery = policyRequirementQuery; PolicyRequirementQuery = policyRequirementQuery;
@@ -94,7 +97,7 @@ public abstract class BaseRequestValidator<T> where T : class
protected async Task ValidateAsync(T context, ValidatedTokenRequest request, protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
CustomValidatorRequestContext validatorContext) CustomValidatorRequestContext validatorContext)
{ {
if (FeatureService.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers)) if (_featureService.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers))
{ {
var validators = DetermineValidationOrder(context, request, validatorContext); var validators = DetermineValidationOrder(context, request, validatorContext);
var allValidationSchemesSuccessful = await ProcessValidatorsAsync(validators); var allValidationSchemesSuccessful = await ProcessValidatorsAsync(validators);
@@ -120,15 +123,29 @@ public abstract class BaseRequestValidator<T> where T : class
} }
// 2. Decide if this user belongs to an organization that requires SSO. // 2. Decide if this user belongs to an organization that requires SSO.
validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType); // TODO: Clean up Feature Flag: Remove this if block: PM-28281
if (validatorContext.SsoRequired) if (!_featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired))
{ {
SetSsoResult(context, validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType);
new Dictionary<string, object> if (validatorContext.SsoRequired)
{ {
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } SetSsoResult(context,
}); new Dictionary<string, object>
return; {
{ "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. // 3. Check if 2FA is required.
@@ -355,36 +372,51 @@ public abstract class BaseRequestValidator<T> where T : class
private async Task<bool> ValidateSsoAsync(T context, ValidatedTokenRequest request, private async Task<bool> ValidateSsoAsync(T context, ValidatedTokenRequest request,
CustomValidatorRequestContext validatorContext) CustomValidatorRequestContext validatorContext)
{ {
validatorContext.SsoRequired = await RequireSsoLoginAsync(validatorContext.User, request.GrantType); // TODO: Clean up Feature Flag: Remove this if block: PM-28281
if (!validatorContext.SsoRequired) 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 // 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 // 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. // review their new recovery token if desired.
// SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery. // 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 // As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been
// evaluated, and recovery will have been performed if requested. // 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 // We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect
// to /login. // to /login.
if (validatorContext.TwoFactorRequired && if (validatorContext.TwoFactorRequired &&
validatorContext.TwoFactorRecoveryRequested) validatorContext.TwoFactorRecoveryRequested)
{ {
SetSsoResult(context, new Dictionary<string, object> SetSsoResult(context, new Dictionary<string, object>
{ {
{ "ErrorModel", new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") } { "ErrorModel", new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") }
}); });
return false;
}
SetSsoResult(context,
new Dictionary<string, object>
{
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
});
return false; return false;
} }
else
SetSsoResult(context, {
new Dictionary<string, object> var ssoValid = await _ssoRequestValidator.ValidateAsync(validatorContext.User, request, validatorContext);
if (ssoValid)
{ {
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } return true;
}); }
return false;
SetValidationErrorResult(context, validatorContext);
return ssoValid;
}
} }
/// <summary> /// <summary>
@@ -651,6 +683,7 @@ public abstract class BaseRequestValidator<T> where T : class
/// <param name="user">user trying to login</param> /// <param name="user">user trying to login</param>
/// <param name="grantType">magic string identifying the grant type requested</param> /// <param name="grantType">magic string identifying the grant type requested</param>
/// <returns>true if sso required; false if not required or already in process</returns> /// <returns>true if sso required; false if not required or already in process</returns>
[Obsolete("This method is deprecated and will be removed in future versions, PM-28281. Please use the SsoRequestValidator scheme instead.")]
private async Task<bool> RequireSsoLoginAsync(User user, string grantType) private async Task<bool> RequireSsoLoginAsync(User user, string grantType)
{ {
if (grantType == "authorization_code" || grantType == "client_credentials") if (grantType == "authorization_code" || grantType == "client_credentials")
@@ -661,7 +694,7 @@ public abstract class BaseRequestValidator<T> where T : class
} }
// Check if user belongs to any organization with an active SSO policy // 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<RequireSsoPolicyRequirement>(user.Id)) ? (await PolicyRequirementQuery.GetAsync<RequireSsoPolicyRequirement>(user.Id))
.SsoRequired .SsoRequired
: await PolicyService.AnyPoliciesApplicableToUserAsync( : await PolicyService.AnyPoliciesApplicableToUserAsync(
@@ -703,7 +736,7 @@ public abstract class BaseRequestValidator<T> where T : class
private async Task SendFailedTwoFactorEmail(User user, TwoFactorProviderType failedAttemptType) 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, await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow,
CurrentContext.IpAddress); CurrentContext.IpAddress);

View File

@@ -36,6 +36,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
IEventService eventService, IEventService eventService,
IDeviceValidator deviceValidator, IDeviceValidator deviceValidator,
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
ISsoRequestValidator ssoRequestValidator,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
ILogger<CustomTokenRequestValidator> logger, ILogger<CustomTokenRequestValidator> logger,
ICurrentContext currentContext, ICurrentContext currentContext,
@@ -56,6 +57,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
eventService, eventService,
deviceValidator, deviceValidator,
twoFactorAuthenticationValidator, twoFactorAuthenticationValidator,
ssoRequestValidator,
organizationUserRepository, organizationUserRepository,
logger, logger,
currentContext, currentContext,

View File

@@ -0,0 +1,19 @@
using Bit.Core.Entities;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators;
/// <summary>
/// Validates whether a user is required to authenticate via SSO based on organization policies.
/// </summary>
public interface ISsoRequestValidator
{
/// <summary>
/// Validates the SSO requirement for a user attempting to authenticate. Sets the error state in the <see cref="CustomValidatorRequestContext.CustomResponse"/> if SSO is required.
/// </summary>
/// <param name="user">The user attempting to authenticate.</param>
/// <param name="request">The token request containing grant type and other authentication details.</param>
/// <param name="context">The validator context to be updated with SSO requirement status and error results if applicable.</param>
/// <returns>true if the user can proceed with authentication; false if SSO is required and the user must be redirected to SSO flow.</returns>
Task<bool> ValidateAsync(User user, ValidatedTokenRequest request, CustomValidatorRequestContext context);
}

View File

@@ -31,6 +31,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
IEventService eventService, IEventService eventService,
IDeviceValidator deviceValidator, IDeviceValidator deviceValidator,
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
ISsoRequestValidator ssoRequestValidator,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
ILogger<ResourceOwnerPasswordValidator> logger, ILogger<ResourceOwnerPasswordValidator> logger,
ICurrentContext currentContext, ICurrentContext currentContext,
@@ -50,6 +51,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
eventService, eventService,
deviceValidator, deviceValidator,
twoFactorAuthenticationValidator, twoFactorAuthenticationValidator,
ssoRequestValidator,
organizationUserRepository, organizationUserRepository,
logger, logger,
currentContext, currentContext,

View File

@@ -0,0 +1,126 @@
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.Models.Api;
using Bit.Core.Services;
using Bit.Identity.IdentityServer.RequestValidationConstants;
using Duende.IdentityModel;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators;
/// <summary>
/// Validates whether a user is required to authenticate via SSO based on organization policies.
/// </summary>
public class SsoRequestValidator(
IPolicyService _policyService,
IFeatureService _featureService,
IUserSsoOrganizationIdentifierQuery _userSsoOrganizationIdentifierQuery,
IPolicyRequirementQuery _policyRequirementQuery) : ISsoRequestValidator
{
/// <summary>
/// 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.
/// </summary>
/// <param name="user">The user attempting to authenticate.</param>
/// <param name="request">The token request containing grant type and other authentication details.</param>
/// <param name="context">The validator context to be updated with SSO requirement status and error results if applicable.</param>
/// <returns>true if the user can proceed with authentication; false if SSO is required and the user must be redirected to SSO flow.</returns>
public async Task<bool> 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;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="user">user trying to login</param>
/// <param name="grantType">magic string identifying the grant type requested</param>
/// <returns>true if sso required; false if not required or already in process</returns>
private async Task<bool> 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<RequireSsoPolicyRequirement>(user.Id))
.SsoRequired
: await _policyService.AnyPoliciesApplicableToUserAsync(
user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
if (ssoRequired)
{
return true;
}
// Default - SSO is not required
return false;
}
/// <summary>
/// Sets the customResponse in the context with the error result for the SSO validation failure.
/// </summary>
/// <param name="context">The validator context to update with error details.</param>
/// <param name="errorMessage">The error message to return to the client.</param>
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<string, object>
{
{ CustomResponseConstants.ResponseKeys.ErrorModel, new ErrorResponseModel(errorMessage) }
};
// Include organization identifier in the response if available
if (!string.IsNullOrEmpty(ssoOrganizationIdentifier))
{
context.CustomResponse[CustomResponseConstants.ResponseKeys.SsoOrganizationIdentifier] = ssoOrganizationIdentifier;
}
}
}

View File

@@ -38,6 +38,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
IEventService eventService, IEventService eventService,
IDeviceValidator deviceValidator, IDeviceValidator deviceValidator,
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
ISsoRequestValidator ssoRequestValidator,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
ILogger<CustomTokenRequestValidator> logger, ILogger<CustomTokenRequestValidator> logger,
ICurrentContext currentContext, ICurrentContext currentContext,
@@ -59,6 +60,7 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
eventService, eventService,
deviceValidator, deviceValidator,
twoFactorAuthenticationValidator, twoFactorAuthenticationValidator,
ssoRequestValidator,
organizationUserRepository, organizationUserRepository,
logger, logger,
currentContext, currentContext,

View File

@@ -26,6 +26,7 @@ public static class ServiceCollectionExtensions
services.AddTransient<IUserDecryptionOptionsBuilder, UserDecryptionOptionsBuilder>(); services.AddTransient<IUserDecryptionOptionsBuilder, UserDecryptionOptionsBuilder>();
services.AddTransient<IDeviceValidator, DeviceValidator>(); services.AddTransient<IDeviceValidator, DeviceValidator>();
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>(); services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
services.AddTransient<ISsoRequestValidator, SsoRequestValidator>();
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>(); services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
services.AddTransient<ISendAuthenticationMethodValidator<ResourcePassword>, SendPasswordRequestValidator>(); services.AddTransient<ISendAuthenticationMethodValidator<ResourcePassword>, SendPasswordRequestValidator>();
services.AddTransient<ISendAuthenticationMethodValidator<EmailOtp>, SendEmailOtpRequestValidator>(); services.AddTransient<ISendAuthenticationMethodValidator<EmailOtp>, SendEmailOtpRequestValidator>();

View File

@@ -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<UserSsoOrganizationIdentifierQuery> 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<IOrganizationUserRepository>()
.GetManyByUserAsync(userId)
.Returns([organizationUser]);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
// Act
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
// Assert
Assert.Equal("test-org-identifier", result);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.GetManyByUserAsync(userId);
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.GetByIdAsync(organization.Id);
}
[Theory, BitAutoData]
public async Task GetSsoOrganizationIdentifierAsync_UserHasNoOrganizations_ReturnsNull(
SutProvider<UserSsoOrganizationIdentifierQuery> sutProvider,
Guid userId)
{
// Arrange
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByUserAsync(userId)
.Returns(Array.Empty<OrganizationUser>());
// Act
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
// Assert
Assert.Null(result);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.GetManyByUserAsync(userId);
await sutProvider.GetDependency<IOrganizationRepository>()
.DidNotReceive()
.GetByIdAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task GetSsoOrganizationIdentifierAsync_UserHasMultipleConfirmedOrganizations_ReturnsNull(
SutProvider<UserSsoOrganizationIdentifierQuery> sutProvider,
Guid userId,
OrganizationUser organizationUser1,
OrganizationUser organizationUser2)
{
// Arrange
organizationUser1.UserId = userId;
organizationUser1.Status = OrganizationUserStatusType.Confirmed;
organizationUser2.UserId = userId;
organizationUser2.Status = OrganizationUserStatusType.Confirmed;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByUserAsync(userId)
.Returns([organizationUser1, organizationUser2]);
// Act
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
// Assert
Assert.Null(result);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.GetManyByUserAsync(userId);
await sutProvider.GetDependency<IOrganizationRepository>()
.DidNotReceive()
.GetByIdAsync(Arg.Any<Guid>());
}
[Theory]
[BitAutoData(OrganizationUserStatusType.Invited)]
[BitAutoData(OrganizationUserStatusType.Accepted)]
[BitAutoData(OrganizationUserStatusType.Revoked)]
public async Task GetSsoOrganizationIdentifierAsync_UserHasOnlyInvitedOrganization_ReturnsNull(
OrganizationUserStatusType status,
SutProvider<UserSsoOrganizationIdentifierQuery> sutProvider,
Guid userId,
OrganizationUser organizationUser)
{
// Arrange
organizationUser.UserId = userId;
organizationUser.Status = status;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByUserAsync(userId)
.Returns([organizationUser]);
// Act
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
// Assert
Assert.Null(result);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.GetManyByUserAsync(userId);
await sutProvider.GetDependency<IOrganizationRepository>()
.DidNotReceive()
.GetByIdAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task GetSsoOrganizationIdentifierAsync_UserHasMixedStatusOrganizations_OnlyOneConfirmed_ReturnsIdentifier(
SutProvider<UserSsoOrganizationIdentifierQuery> 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<IOrganizationUserRepository>()
.GetManyByUserAsync(userId)
.Returns(new[] { confirmedOrgUser, invitedOrgUser, revokedOrgUser });
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
// Act
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
// Assert
Assert.Equal("mixed-status-org", result);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.GetManyByUserAsync(userId);
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.GetByIdAsync(organization.Id);
}
[Theory, BitAutoData]
public async Task GetSsoOrganizationIdentifierAsync_OrganizationNotFound_ReturnsNull(
SutProvider<UserSsoOrganizationIdentifierQuery> sutProvider,
Guid userId,
OrganizationUser organizationUser)
{
// Arrange
organizationUser.UserId = userId;
organizationUser.Status = OrganizationUserStatusType.Confirmed;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByUserAsync(userId)
.Returns([organizationUser]);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organizationUser.OrganizationId)
.Returns((Organization)null);
// Act
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
// Assert
Assert.Null(result);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.GetManyByUserAsync(userId);
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.GetByIdAsync(organizationUser.OrganizationId);
}
[Theory, BitAutoData]
public async Task GetSsoOrganizationIdentifierAsync_OrganizationIdentifierIsNull_ReturnsNull(
SutProvider<UserSsoOrganizationIdentifierQuery> sutProvider,
Guid userId,
Organization organization,
OrganizationUser organizationUser)
{
// Arrange
organizationUser.UserId = userId;
organizationUser.OrganizationId = organization.Id;
organizationUser.Status = OrganizationUserStatusType.Confirmed;
organization.Identifier = null;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByUserAsync(userId)
.Returns(new[] { organizationUser });
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
// Act
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
// Assert
Assert.Null(result);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.GetManyByUserAsync(userId);
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.GetByIdAsync(organization.Id);
}
[Theory, BitAutoData]
public async Task GetSsoOrganizationIdentifierAsync_OrganizationIdentifierIsEmpty_ReturnsEmpty(
SutProvider<UserSsoOrganizationIdentifierQuery> 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<IOrganizationUserRepository>()
.GetManyByUserAsync(userId)
.Returns(new[] { organizationUser });
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
// Act
var result = await sutProvider.Sut.GetSsoOrganizationIdentifierAsync(userId);
// Assert
Assert.Equal(string.Empty, result);
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.GetManyByUserAsync(userId);
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.GetByIdAsync(organization.Id);
}
}

View File

@@ -44,14 +44,17 @@ internal class CustomValidatorRequestContextCustomization : ICustomization
/// <see cref="CustomValidatorRequestContext.TwoFactorRecoveryRequested"/>, and /// <see cref="CustomValidatorRequestContext.TwoFactorRecoveryRequested"/>, and
/// <see cref="CustomValidatorRequestContext.SsoRequired" /> should initialize false, /// <see cref="CustomValidatorRequestContext.SsoRequired" /> should initialize false,
/// and are made truthy in context upon evaluation of a request. Do not allow AutoFixture to eagerly make these /// 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 <see cref="Bit.Identity.IdentityServer.RequestValidators.BaseRequestValidator{T}" /> /// truthy; that is the responsibility of the <see cref="Bit.Identity.IdentityServer.RequestValidators.BaseRequestValidator{T}" />.
/// ValidationErrorResult and CustomResponse should also be null initially; they are hydrated during the validation process.
/// </summary> /// </summary>
public void Customize(IFixture fixture) public void Customize(IFixture fixture)
{ {
fixture.Customize<CustomValidatorRequestContext>(composer => composer fixture.Customize<CustomValidatorRequestContext>(composer => composer
.With(o => o.RememberMeRequested, false) .With(o => o.RememberMeRequested, false)
.With(o => o.TwoFactorRecoveryRequested, 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));
} }
} }

View File

@@ -21,6 +21,7 @@ using Bit.Identity.IdentityServer;
using Bit.Identity.IdentityServer.RequestValidators; using Bit.Identity.IdentityServer.RequestValidators;
using Bit.Identity.Test.Wrappers; using Bit.Identity.Test.Wrappers;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Duende.IdentityModel;
using Duende.IdentityServer.Validation; using Duende.IdentityServer.Validation;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -42,6 +43,7 @@ public class BaseRequestValidatorTests
private readonly IEventService _eventService; private readonly IEventService _eventService;
private readonly IDeviceValidator _deviceValidator; private readonly IDeviceValidator _deviceValidator;
private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator; private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator;
private readonly ISsoRequestValidator _ssoRequestValidator;
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly FakeLogger<BaseRequestValidatorTests> _logger; private readonly FakeLogger<BaseRequestValidatorTests> _logger;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
@@ -65,6 +67,7 @@ public class BaseRequestValidatorTests
_eventService = Substitute.For<IEventService>(); _eventService = Substitute.For<IEventService>();
_deviceValidator = Substitute.For<IDeviceValidator>(); _deviceValidator = Substitute.For<IDeviceValidator>();
_twoFactorAuthenticationValidator = Substitute.For<ITwoFactorAuthenticationValidator>(); _twoFactorAuthenticationValidator = Substitute.For<ITwoFactorAuthenticationValidator>();
_ssoRequestValidator = Substitute.For<ISsoRequestValidator>();
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>(); _organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
_logger = new FakeLogger<BaseRequestValidatorTests>(); _logger = new FakeLogger<BaseRequestValidatorTests>();
_currentContext = Substitute.For<ICurrentContext>(); _currentContext = Substitute.For<ICurrentContext>();
@@ -85,6 +88,7 @@ public class BaseRequestValidatorTests
_eventService, _eventService,
_deviceValidator, _deviceValidator,
_twoFactorAuthenticationValidator, _twoFactorAuthenticationValidator,
_ssoRequestValidator,
_organizationUserRepository, _organizationUserRepository,
_logger, _logger,
_currentContext, _currentContext,
@@ -151,6 +155,7 @@ public class BaseRequestValidatorTests
// Arrange // Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult); var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass // 1 -> to pass
_sut.isValid = true; _sut.isValid = true;
@@ -162,9 +167,9 @@ public class BaseRequestValidatorTests
// 4 -> set up device validator to fail // 4 -> set up device validator to fail
requestContext.KnownDevice = false; requestContext.KnownDevice = false;
tokenRequest.GrantType = "password"; tokenRequest.GrantType = OidcConstants.GrantTypes.Password;
_deviceValidator _deviceValidator
.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>()) .ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(false)); .Returns(Task.FromResult(false));
// 5 -> not legacy user // 5 -> not legacy user
@@ -192,6 +197,7 @@ public class BaseRequestValidatorTests
// Arrange // Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult); var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass // 1 -> to pass
_sut.isValid = true; _sut.isValid = true;
@@ -203,12 +209,13 @@ public class BaseRequestValidatorTests
// 4 -> set up device validator to pass // 4 -> set up device validator to pass
_deviceValidator _deviceValidator
.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>()) .ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(true)); .Returns(Task.FromResult(true));
// 5 -> not legacy user // 5 -> not legacy user
_userService.IsLegacyUser(Arg.Any<string>()) _userService.IsLegacyUser(Arg.Any<string>())
.Returns(false); .Returns(false);
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData _userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
{ {
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
@@ -236,6 +243,7 @@ public class BaseRequestValidatorTests
// Arrange // Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult); var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass // 1 -> to pass
_sut.isValid = true; _sut.isValid = true;
@@ -262,12 +270,13 @@ public class BaseRequestValidatorTests
// 4 -> set up device validator to pass // 4 -> set up device validator to pass
_deviceValidator _deviceValidator
.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>()) .ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(true)); .Returns(Task.FromResult(true));
// 5 -> not legacy user // 5 -> not legacy user
_userService.IsLegacyUser(Arg.Any<string>()) _userService.IsLegacyUser(Arg.Any<string>())
.Returns(false); .Returns(false);
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData _userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
{ {
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
@@ -299,6 +308,7 @@ public class BaseRequestValidatorTests
// Arrange // Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult); var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass // 1 -> to pass
_sut.isValid = true; _sut.isValid = true;
@@ -319,10 +329,19 @@ public class BaseRequestValidatorTests
// 2 -> will result to false with no extra configuration // 2 -> will result to false with no extra configuration
// 3 -> set two factor to be required // 3 -> set two factor to be required
requestContext.User.TwoFactorProviders = "{\"1\":{\"Enabled\":true,\"MetaData\":{\"Email\":\"user@test.dev\"}}}";
_twoFactorAuthenticationValidator _twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest) .RequiresTwoFactorAsync(requestContext.User, tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(true, null))); .Returns(Task.FromResult(new Tuple<bool, Organization>(true, null)));
_twoFactorAuthenticationValidator
.BuildTwoFactorResultAsync(requestContext.User, null)
.Returns(Task.FromResult(new Dictionary<string, object>
{
{ "TwoFactorProviders", new[] { "0", "1" } },
{ "TwoFactorProviders2", new Dictionary<string, object>{{"Email", null}} }
}));
// Act // Act
await _sut.ValidateAsync(context); await _sut.ValidateAsync(context);
@@ -330,7 +349,10 @@ public class BaseRequestValidatorTests
Assert.True(context.GrantResult.IsError); Assert.True(context.GrantResult.IsError);
// Assert that the auth request was NOT consumed // Assert that the auth request was NOT consumed
await _authRequestRepository.DidNotReceive().ReplaceAsync(Arg.Any<AuthRequest>()); await _authRequestRepository.DidNotReceive().ReplaceAsync(authRequest);
// Assert that the error is for 2fa
Assert.Equal("Two-factor authentication required.", context.GrantResult.ErrorDescription);
} }
[Theory] [Theory]
@@ -420,6 +442,7 @@ public class BaseRequestValidatorTests
{ "TwoFactorProviders", new[] { "0", "1" } }, { "TwoFactorProviders", new[] { "0", "1" } },
{ "TwoFactorProviders2", new Dictionary<string, object>() } { "TwoFactorProviders2", new Dictionary<string, object>() }
}; };
_twoFactorAuthenticationValidator _twoFactorAuthenticationValidator
.BuildTwoFactorResultAsync(user, null) .BuildTwoFactorResultAsync(user, null)
.Returns(Task.FromResult(twoFactorResultDict)); .Returns(Task.FromResult(twoFactorResultDict));
@@ -428,6 +451,8 @@ public class BaseRequestValidatorTests
await _sut.ValidateAsync(context); await _sut.ValidateAsync(context);
// Assert // Assert
Assert.Equal("Two-factor authentication required.", context.GrantResult.ErrorDescription);
// Verify that the failed 2FA email was NOT sent for remember token expiration // Verify that the failed 2FA email was NOT sent for remember token expiration
await _mailService.DidNotReceive() await _mailService.DidNotReceive()
.SendFailedTwoFactorAttemptEmailAsync(Arg.Any<string>(), Arg.Any<TwoFactorProviderType>(), .SendFailedTwoFactorAttemptEmailAsync(Arg.Any<string>(), Arg.Any<TwoFactorProviderType>(),
@@ -1243,6 +1268,343 @@ public class BaseRequestValidatorTests
} }
} }
/// <summary>
/// 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.
/// </summary>
[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<Guid>(), 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<User>(), Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>());
}
/// <summary>
/// Tests that when RedirectOnSsoRequired is ENABLED, the new ISsoRequestValidator is used
/// instead of the legacy RequireSsoLoginAsync method.
/// </summary>
[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<User>(),
Arg.Any<ValidatedTokenRequest>(),
Arg.Any<CustomValidatorRequestContext>())
.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<string, object>
{
{ "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<Guid>(), Arg.Any<PolicyType>(), Arg.Any<OrganizationUserStatusType>());
}
/// <summary>
/// Tests that when RedirectOnSsoRequired is ENABLED and SSO is NOT required,
/// authentication continues successfully through the new validation path.
/// </summary>
[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<User>(),
Arg.Any<ValidatedTokenRequest>(),
Arg.Any<CustomValidatorRequestContext>())
.Returns(Task.FromResult(true));
// No 2FA required
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
// Device validation passes
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(true));
// User is not legacy
_userService.IsLegacyUser(Arg.Any<string>()).Returns(false);
_userAccountKeysQuery.Run(Arg.Any<User>()).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);
}
/// <summary>
/// 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.
/// </summary>
[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<string, object>
{
{ "ErrorModel", new ErrorResponseModel("SSO authentication is required.") },
{ "SsoOrganizationIdentifier", "test-org-identifier" }
};
var context = CreateContext(tokenRequest, requestContext, grantResult);
_ssoRequestValidator.ValidateAsync(
Arg.Any<User>(),
Arg.Any<ValidatedTokenRequest>(),
Arg.Any<CustomValidatorRequestContext>())
.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"]);
}
/// <summary>
/// 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).
/// </summary>
[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<bool, Organization>(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<Guid>(), 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);
}
/// <summary>
/// Tests that when RedirectOnSsoRequired is ENABLED and recovery code is used for SSO-required user,
/// the SsoRequestValidator provides the recovery-specific error message.
/// </summary>
[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<bool, Organization>(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<string, object>
{
{ "ErrorModel", new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") }
};
_ssoRequestValidator.ValidateAsync(
Arg.Any<User>(),
Arg.Any<ValidatedTokenRequest>(),
Arg.Any<CustomValidatorRequestContext>())
.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<CustomValidatorRequestContext>(ctx => ctx.TwoFactorRecoveryRequested));
// Verify legacy path was NOT used
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
Arg.Any<Guid>(), Arg.Any<PolicyType>(), Arg.Any<OrganizationUserStatusType>());
}
private BaseRequestValidationContextFake CreateContext( private BaseRequestValidationContextFake CreateContext(
ValidatedTokenRequest tokenRequest, ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext, CustomValidatorRequestContext requestContext,

View File

@@ -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<SsoRequestValidator> 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<IPolicyService>().DidNotReceive()
.AnyPoliciesApplicableToUserAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>(), Arg.Any<OrganizationUserStatusType>());
await sutProvider.GetDependency<IPolicyRequirementQuery>().DidNotReceive()
.GetAsync<RequireSsoPolicyRequirement>(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async void ValidateAsync_SsoNotRequired_RequirementPolicyFeatureFlagEnabled_ReturnsTrue(
User user,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
SutProvider<SsoRequestValidator> sutProvider)
{
// Arrange
request.GrantType = OidcConstants.GrantTypes.Password;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var requirement = new RequireSsoPolicyRequirement { SsoRequired = false };
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(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<IPolicyRequirementQuery>().Received(1).GetAsync<RequireSsoPolicyRequirement>(user.Id);
await sutProvider.GetDependency<IPolicyService>().DidNotReceive()
.AnyPoliciesApplicableToUserAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>(), Arg.Any<OrganizationUserStatusType>());
}
[Theory, BitAutoData]
public async void ValidateAsync_SsoNotRequired_RequirementPolicyFeatureFlagDisabled_ReturnsTrue(
User user,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
SutProvider<SsoRequestValidator> sutProvider)
{
// Arrange
request.GrantType = OidcConstants.GrantTypes.Password;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false);
sutProvider.GetDependency<IPolicyService>().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<IPolicyService>().Received(1).AnyPoliciesApplicableToUserAsync(
user.Id,
PolicyType.RequireSso,
OrganizationUserStatusType.Confirmed);
await sutProvider.GetDependency<IPolicyRequirementQuery>().DidNotReceive()
.GetAsync<RequireSsoPolicyRequirement>(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async void ValidateAsync_SsoRequired_RequirementPolicyFeatureFlagEnabled_ReturnsFalse(
User user,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
SutProvider<SsoRequestValidator> sutProvider)
{
// Arrange
request.GrantType = OidcConstants.GrantTypes.Password;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
.Returns(requirement);
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
.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<SsoRequestValidator> sutProvider)
{
// Arrange
request.GrantType = OidcConstants.GrantTypes.Password;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false);
sutProvider.GetDependency<IPolicyService>().AnyPoliciesApplicableToUserAsync(
user.Id,
PolicyType.RequireSso,
OrganizationUserStatusType.Confirmed)
.Returns(true);
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
.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<SsoRequestValidator> sutProvider)
{
// Arrange
request.GrantType = OidcConstants.GrantTypes.Password;
context.TwoFactorRecoveryRequested = true;
context.TwoFactorRequired = true;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
.Returns(requirement);
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
.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<SsoRequestValidator> sutProvider)
{
// Arrange
request.GrantType = OidcConstants.GrantTypes.Password;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
.Returns(requirement);
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
.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<SsoRequestValidator> sutProvider)
{
// Arrange
request.GrantType = grantType;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
.Returns(requirement);
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
.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<SsoRequestValidator> sutProvider)
{
// Arrange
request.GrantType = OidcConstants.GrantTypes.Password;
context.SsoRequired = true; // Start with true to ensure it gets updated
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var requirement = new RequireSsoPolicyRequirement { SsoRequired = false };
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(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<SsoRequestValidator> sutProvider)
{
// Arrange
const string orgIdentifier = "test-organization";
request.GrantType = OidcConstants.GrantTypes.Password;
context.User = user;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
.Returns(requirement);
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
.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<IUserSsoOrganizationIdentifierQuery>()
.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<SsoRequestValidator> sutProvider)
{
// Arrange
request.GrantType = OidcConstants.GrantTypes.Password;
context.User = user;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
.Returns(requirement);
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
.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<IUserSsoOrganizationIdentifierQuery>()
.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<SsoRequestValidator> sutProvider)
{
// Arrange
request.GrantType = OidcConstants.GrantTypes.Password;
context.User = user;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(user.Id)
.Returns(requirement);
sutProvider.GetDependency<IUserSsoOrganizationIdentifierQuery>()
.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<IUserSsoOrganizationIdentifierQuery>()
.Received(1)
.GetSsoOrganizationIdentifierAsync(user.Id);
}
[Theory, BitAutoData]
public async void ValidateAsync_SsoNotRequired_DoesNotCallOrganizationIdentifierQuery(
User user,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request,
SutProvider<SsoRequestValidator> sutProvider)
{
// Arrange
request.GrantType = OidcConstants.GrantTypes.Password;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var requirement = new RequireSsoPolicyRequirement { SsoRequired = false };
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<RequireSsoPolicyRequirement>(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<IUserSsoOrganizationIdentifierQuery>()
.DidNotReceive()
.GetSsoOrganizationIdentifierAsync(Arg.Any<Guid>());
}
}

View File

@@ -32,7 +32,7 @@ public class TwoFactorAuthenticationValidatorTests
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmail2faSessionTokenable; private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmail2faSessionTokenable;
private readonly ITwoFactorIsEnabledQuery _twoFactorenabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorEnabledQuery;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly TwoFactorAuthenticationValidator _sut; private readonly TwoFactorAuthenticationValidator _sut;
@@ -45,7 +45,7 @@ public class TwoFactorAuthenticationValidatorTests
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>(); _organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
_organizationRepository = Substitute.For<IOrganizationRepository>(); _organizationRepository = Substitute.For<IOrganizationRepository>();
_ssoEmail2faSessionTokenable = Substitute.For<IDataProtectorTokenFactory<SsoEmail2faSessionTokenable>>(); _ssoEmail2faSessionTokenable = Substitute.For<IDataProtectorTokenFactory<SsoEmail2faSessionTokenable>>();
_twoFactorenabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>(); _twoFactorEnabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>();
_currentContext = Substitute.For<ICurrentContext>(); _currentContext = Substitute.For<ICurrentContext>();
_sut = new TwoFactorAuthenticationValidator( _sut = new TwoFactorAuthenticationValidator(
@@ -56,7 +56,7 @@ public class TwoFactorAuthenticationValidatorTests
_organizationUserRepository, _organizationUserRepository,
_organizationRepository, _organizationRepository,
_ssoEmail2faSessionTokenable, _ssoEmail2faSessionTokenable,
_twoFactorenabledQuery, _twoFactorEnabledQuery,
_currentContext); _currentContext);
} }

View File

@@ -54,6 +54,7 @@ IBaseRequestValidatorTestWrapper
IEventService eventService, IEventService eventService,
IDeviceValidator deviceValidator, IDeviceValidator deviceValidator,
ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator, ITwoFactorAuthenticationValidator twoFactorAuthenticationValidator,
ISsoRequestValidator ssoRequestValidator,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
ILogger logger, ILogger logger,
ICurrentContext currentContext, ICurrentContext currentContext,
@@ -73,6 +74,7 @@ IBaseRequestValidatorTestWrapper
eventService, eventService,
deviceValidator, deviceValidator,
twoFactorAuthenticationValidator, twoFactorAuthenticationValidator,
ssoRequestValidator,
organizationUserRepository, organizationUserRepository,
logger, logger,
currentContext, currentContext,
@@ -132,12 +134,17 @@ IBaseRequestValidatorTestWrapper
protected override void SetTwoFactorResult( protected override void SetTwoFactorResult(
BaseRequestValidationContextFake context, BaseRequestValidationContextFake context,
Dictionary<string, object> customResponse) Dictionary<string, object> customResponse)
{ } {
context.GrantResult = new GrantValidationResult(
TokenRequestErrors.InvalidGrant, "Two-factor authentication required.", customResponse);
}
protected override void SetValidationErrorResult( protected override void SetValidationErrorResult(
BaseRequestValidationContextFake context, BaseRequestValidationContextFake context,
CustomValidatorRequestContext requestContext) CustomValidatorRequestContext requestContext)
{ } {
context.GrantResult.IsError = true;
}
protected override Task<bool> ValidateContextAsync( protected override Task<bool> ValidateContextAsync(
BaseRequestValidationContextFake context, BaseRequestValidationContextFake context,