diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index fead9947a0..ccfa4a6e0e 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -156,6 +156,7 @@ public static class FeatureFlagKeys
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword =
"pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password";
+ public const string RecoveryCodeSupportForSsoRequiredUsers = "pm-21153-recovery-code-support-for-sso-required";
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
/* Autofill Team */
diff --git a/src/Identity/IdentityServer/CustomValidatorRequestContext.cs b/src/Identity/IdentityServer/CustomValidatorRequestContext.cs
index a709a47cb2..e16c8ad695 100644
--- a/src/Identity/IdentityServer/CustomValidatorRequestContext.cs
+++ b/src/Identity/IdentityServer/CustomValidatorRequestContext.cs
@@ -27,6 +27,12 @@ public class CustomValidatorRequestContext
///
public bool TwoFactorRequired { get; set; } = false;
///
+ /// Whether the user has requested recovery of their 2FA methods using their one-time
+ /// recovery code.
+ ///
+ ///
+ public bool TwoFactorRecoveryRequested { get; set; } = false;
+ ///
/// This communicates whether or not SSO is required for the user to authenticate.
///
public bool SsoRequired { get; set; } = false;
@@ -42,10 +48,13 @@ public class CustomValidatorRequestContext
/// This will be null if the authentication request is successful.
///
public Dictionary CustomResponse { get; set; }
-
///
/// A validated auth request
///
///
public AuthRequest ValidatedAuthRequest { get; set; }
+ ///
+ /// Whether the user has requested a Remember Me token for their current device.
+ ///
+ public bool RememberMeRequested { get; set; } = false;
}
diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs
index b976775aca..224c7a1866 100644
--- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs
+++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs
@@ -1,4 +1,5 @@
// FIXME: Update this file to be null safe and then delete the line below
+
#nullable disable
using System.Security.Claims;
@@ -68,7 +69,7 @@ public abstract class BaseRequestValidator where T : class
IAuthRequestRepository authRequestRepository,
IMailService mailService,
IUserAccountKeysQuery userAccountKeysQuery
- )
+ )
{
_userManager = userManager;
_userService = userService;
@@ -93,125 +94,141 @@ public abstract class BaseRequestValidator where T : class
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
CustomValidatorRequestContext validatorContext)
{
- // 1. We need to check if the user's master password hash is correct.
- var valid = await ValidateContextAsync(context, validatorContext);
- var user = validatorContext.User;
- if (!valid)
+ if (FeatureService.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers))
{
- await UpdateFailedAuthDetailsAsync(user);
-
- await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user);
- return;
- }
-
- // 2. Decide if this user belongs to an organization that requires SSO.
- validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType);
- if (validatorContext.SsoRequired)
- {
- SetSsoResult(context,
- new Dictionary
- {
- { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
- });
- return;
- }
-
- // 3. Check if 2FA is required.
- (validatorContext.TwoFactorRequired, var twoFactorOrganization) =
- await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request);
-
- // This flag is used to determine if the user wants a rememberMe token sent when
- // authentication is successful.
- var returnRememberMeToken = false;
-
- if (validatorContext.TwoFactorRequired)
- {
- var twoFactorToken = request.Raw["TwoFactorToken"];
- var twoFactorProvider = request.Raw["TwoFactorProvider"];
- var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) &&
- !string.IsNullOrWhiteSpace(twoFactorProvider);
-
- // 3a. Response for 2FA required and not provided state.
- if (!validTwoFactorRequest ||
- !Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType))
+ var validators = DetermineValidationOrder(context, request, validatorContext);
+ var allValidationSchemesSuccessful = await ProcessValidatorsAsync(validators);
+ if (!allValidationSchemesSuccessful)
{
- var resultDict = await _twoFactorAuthenticationValidator
- .BuildTwoFactorResultAsync(user, twoFactorOrganization);
- if (resultDict == null)
+ // Each validation task is responsible for setting its own non-success status, if applicable.
+ return;
+ }
+ await BuildSuccessResultAsync(validatorContext.User, context, validatorContext.Device,
+ validatorContext.RememberMeRequested);
+ }
+ else
+ {
+ // 1. We need to check if the user's master password hash is correct.
+ var valid = await ValidateContextAsync(context, validatorContext);
+ var user = validatorContext.User;
+ if (!valid)
+ {
+ await UpdateFailedAuthDetailsAsync(user);
+
+ await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user);
+ return;
+ }
+
+ // 2. Decide if this user belongs to an organization that requires SSO.
+ validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType);
+ if (validatorContext.SsoRequired)
+ {
+ SetSsoResult(context,
+ new Dictionary
+ {
+ { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") }
+ });
+ return;
+ }
+
+ // 3. Check if 2FA is required.
+ (validatorContext.TwoFactorRequired, var twoFactorOrganization) =
+ await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request);
+
+ // This flag is used to determine if the user wants a rememberMe token sent when
+ // authentication is successful.
+ var returnRememberMeToken = false;
+
+ if (validatorContext.TwoFactorRequired)
+ {
+ var twoFactorToken = request.Raw["TwoFactorToken"];
+ var twoFactorProvider = request.Raw["TwoFactorProvider"];
+ var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) &&
+ !string.IsNullOrWhiteSpace(twoFactorProvider);
+
+ // 3a. Response for 2FA required and not provided state.
+ if (!validTwoFactorRequest ||
+ !Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType))
{
- await BuildErrorResultAsync("No two-step providers enabled.", false, context, user);
+ var resultDict = await _twoFactorAuthenticationValidator
+ .BuildTwoFactorResultAsync(user, twoFactorOrganization);
+ if (resultDict == null)
+ {
+ await BuildErrorResultAsync("No two-step providers enabled.", false, context, user);
+ return;
+ }
+
+ // Include Master Password Policy in 2FA response.
+ resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user));
+ SetTwoFactorResult(context, resultDict);
return;
}
- // Include Master Password Policy in 2FA response.
- resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user));
- SetTwoFactorResult(context, resultDict);
+ var twoFactorTokenValid =
+ await _twoFactorAuthenticationValidator
+ .VerifyTwoFactorAsync(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken);
+
+ // 3b. Response for 2FA required but request is not valid or remember token expired state.
+ if (!twoFactorTokenValid)
+ {
+ // The remember me token has expired.
+ if (twoFactorProviderType == TwoFactorProviderType.Remember)
+ {
+ var resultDict = await _twoFactorAuthenticationValidator
+ .BuildTwoFactorResultAsync(user, twoFactorOrganization);
+
+ // Include Master Password Policy in 2FA response
+ resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user));
+ SetTwoFactorResult(context, resultDict);
+ }
+ else
+ {
+ await SendFailedTwoFactorEmail(user, twoFactorProviderType);
+ await UpdateFailedAuthDetailsAsync(user);
+ await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user);
+ }
+
+ return;
+ }
+
+ // 3c. When the 2FA authentication is successful, we can check if the user wants a
+ // rememberMe token.
+ var twoFactorRemember = request.Raw["TwoFactorRemember"] == "1";
+ // Check if the user wants a rememberMe token.
+ if (twoFactorRemember
+ // if the 2FA auth was rememberMe do not send another token.
+ && twoFactorProviderType != TwoFactorProviderType.Remember)
+ {
+ returnRememberMeToken = true;
+ }
+ }
+
+ // 4. Check if the user is logging in from a new device.
+ var deviceValid = await _deviceValidator.ValidateRequestDeviceAsync(request, validatorContext);
+ if (!deviceValid)
+ {
+ SetValidationErrorResult(context, validatorContext);
+ await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn);
return;
}
- var twoFactorTokenValid =
- await _twoFactorAuthenticationValidator
- .VerifyTwoFactorAsync(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken);
-
- // 3b. Response for 2FA required but request is not valid or remember token expired state.
- if (!twoFactorTokenValid)
+ // 5. Force legacy users to the web for migration.
+ if (UserService.IsLegacyUser(user) && request.ClientId != "web")
{
- // The remember me token has expired.
- if (twoFactorProviderType == TwoFactorProviderType.Remember)
- {
- var resultDict = await _twoFactorAuthenticationValidator
- .BuildTwoFactorResultAsync(user, twoFactorOrganization);
-
- // Include Master Password Policy in 2FA response
- resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user));
- SetTwoFactorResult(context, resultDict);
- }
- else
- {
- await SendFailedTwoFactorEmail(user, twoFactorProviderType);
- await UpdateFailedAuthDetailsAsync(user);
- await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user);
- }
+ await FailAuthForLegacyUserAsync(user, context);
return;
}
- // 3c. When the 2FA authentication is successful, we can check if the user wants a
- // rememberMe token.
- var twoFactorRemember = request.Raw["TwoFactorRemember"] == "1";
- // Check if the user wants a rememberMe token.
- if (twoFactorRemember
- // if the 2FA auth was rememberMe do not send another token.
- && twoFactorProviderType != TwoFactorProviderType.Remember)
+ // TODO: PM-24324 - This should be its own validator at some point.
+ // 6. Auth request handling
+ if (validatorContext.ValidatedAuthRequest != null)
{
- returnRememberMeToken = true;
+ validatorContext.ValidatedAuthRequest.AuthenticationDate = DateTime.UtcNow;
+ await _authRequestRepository.ReplaceAsync(validatorContext.ValidatedAuthRequest);
}
- }
- // 4. Check if the user is logging in from a new device.
- var deviceValid = await _deviceValidator.ValidateRequestDeviceAsync(request, validatorContext);
- if (!deviceValid)
- {
- SetValidationErrorResult(context, validatorContext);
- await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn);
- return;
+ await BuildSuccessResultAsync(user, context, validatorContext.Device, returnRememberMeToken);
}
-
- // 5. Force legacy users to the web for migration.
- if (UserService.IsLegacyUser(user) && request.ClientId != "web")
- {
- await FailAuthForLegacyUserAsync(user, context);
- return;
- }
-
- // TODO: PM-24324 - This should be its own validator at some point.
- // 6. Auth request handling
- if (validatorContext.ValidatedAuthRequest != null)
- {
- validatorContext.ValidatedAuthRequest.AuthenticationDate = DateTime.UtcNow;
- await _authRequestRepository.ReplaceAsync(validatorContext.ValidatedAuthRequest);
- }
-
- await BuildSuccessResultAsync(user, context, validatorContext.Device, returnRememberMeToken);
}
protected async Task FailAuthForLegacyUserAsync(User user, T context)
@@ -223,6 +240,302 @@ public abstract class BaseRequestValidator where T : class
protected abstract Task ValidateContextAsync(T context, CustomValidatorRequestContext validatorContext);
+ ///
+ /// Composer for validation schemes.
+ ///
+ /// The current request context.
+ ///
+ ///
+ /// A composed array of validation scheme delegates to evaluate in order.
+ private Func>[] DetermineValidationOrder(T context, ValidatedTokenRequest request,
+ CustomValidatorRequestContext validatorContext)
+ {
+ if (RecoveryCodeRequestForSsoRequiredUserScenario())
+ {
+ // Support valid requests to recover 2FA (with account code) for users who require SSO
+ // by organization membership.
+ // This requires an evaluation of 2FA validity in front of SSO, and an opportunity for the 2FA
+ // validation to perform the recovery as part of scheme validation based on the request.
+ return
+ [
+ () => ValidateMasterPasswordAsync(context, validatorContext),
+ () => ValidateTwoFactorAsync(context, request, validatorContext),
+ () => ValidateSsoAsync(context, request, validatorContext),
+ () => ValidateNewDeviceAsync(context, request, validatorContext),
+ () => ValidateLegacyMigrationAsync(context, request, validatorContext),
+ () => ValidateAuthRequestAsync(validatorContext)
+ ];
+ }
+ else
+ {
+ // The typical validation scenario.
+ return
+ [
+ () => ValidateMasterPasswordAsync(context, validatorContext),
+ () => ValidateSsoAsync(context, request, validatorContext),
+ () => ValidateTwoFactorAsync(context, request, validatorContext),
+ () => ValidateNewDeviceAsync(context, request, validatorContext),
+ () => ValidateLegacyMigrationAsync(context, request, validatorContext),
+ () => ValidateAuthRequestAsync(validatorContext)
+ ];
+ }
+
+ bool RecoveryCodeRequestForSsoRequiredUserScenario()
+ {
+ var twoFactorProvider = request.Raw["TwoFactorProvider"];
+ var twoFactorToken = request.Raw["TwoFactorToken"];
+
+ // Both provider and token must be present;
+ // Validity of the token for a given provider will be evaluated by the TwoFactorAuthenticationValidator.
+ if (string.IsNullOrWhiteSpace(twoFactorProvider) || string.IsNullOrWhiteSpace(twoFactorToken))
+ {
+ return false;
+ }
+
+ if (!int.TryParse(twoFactorProvider, out var providerValue))
+ {
+ return false;
+ }
+
+ return providerValue == (int)TwoFactorProviderType.RecoveryCode;
+ }
+ }
+
+ ///
+ /// Processes the validation schemes sequentially.
+ /// Each validator is responsible for setting error context responses on failure and adding itself to the
+ /// validatorContext's CompletedValidationSchemes (only) on success.
+ /// Failure of any scheme to validate will short-circuit the collection, causing the validation error to be
+ /// returned and further schemes to not be evaluated.
+ ///
+ /// The collection of validation schemes as composed in
+ /// true if all schemes validated successfully, false if any failed.
+ private static async Task ProcessValidatorsAsync(params Func>[] validators)
+ {
+ foreach (var validator in validators)
+ {
+ if (!await validator())
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Validates the user's Master Password hash.
+ ///
+ /// The current request context.
+ ///
+ /// true if the scheme successfully passed validation, otherwise false.
+ private async Task ValidateMasterPasswordAsync(T context, CustomValidatorRequestContext validatorContext)
+ {
+ var valid = await ValidateContextAsync(context, validatorContext);
+ var user = validatorContext.User;
+ if (valid)
+ {
+ return true;
+ }
+
+ await UpdateFailedAuthDetailsAsync(user);
+
+ await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user);
+ return false;
+ }
+
+ ///
+ /// Validates the user's organization-enforced Single Sign-on (SSO) requirement.
+ ///
+ /// The current request context.
+ ///
+ ///
+ /// true if the scheme successfully passed validation, otherwise false.
+ ///
+ private async Task ValidateSsoAsync(T context, ValidatedTokenRequest request,
+ CustomValidatorRequestContext validatorContext)
+ {
+ 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
+ {
+ { "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;
+ }
+
+ ///
+ /// Validates the user's Multi-Factor Authentication (2FA) scheme.
+ ///
+ /// The current request context.
+ ///
+ ///
+ /// true if the scheme successfully passed validation, otherwise false.
+ private async Task ValidateTwoFactorAsync(T context, ValidatedTokenRequest request,
+ CustomValidatorRequestContext validatorContext)
+ {
+ (validatorContext.TwoFactorRequired, var twoFactorOrganization) =
+ await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(validatorContext.User, request);
+
+ if (!validatorContext.TwoFactorRequired)
+ {
+ return true;
+ }
+
+ var twoFactorToken = request.Raw["TwoFactorToken"];
+ var twoFactorProvider = request.Raw["TwoFactorProvider"];
+ var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) &&
+ !string.IsNullOrWhiteSpace(twoFactorProvider);
+
+ // 3a. Response for 2FA required and not provided state.
+ if (!validTwoFactorRequest ||
+ !Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType))
+ {
+ var resultDict = await _twoFactorAuthenticationValidator
+ .BuildTwoFactorResultAsync(validatorContext.User, twoFactorOrganization);
+ if (resultDict == null)
+ {
+ await BuildErrorResultAsync("No two-step providers enabled.", false, context, validatorContext.User);
+ return false;
+ }
+
+ // Include Master Password Policy in 2FA response.
+ resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(validatorContext.User));
+ SetTwoFactorResult(context, resultDict);
+ return false;
+ }
+
+ var twoFactorTokenValid =
+ await _twoFactorAuthenticationValidator
+ .VerifyTwoFactorAsync(validatorContext.User, twoFactorOrganization, twoFactorProviderType,
+ twoFactorToken);
+
+ // 3b. Response for 2FA required but request is not valid or remember token expired state.
+ if (!twoFactorTokenValid)
+ {
+ // The remember me token has expired.
+ if (twoFactorProviderType == TwoFactorProviderType.Remember)
+ {
+ var resultDict = await _twoFactorAuthenticationValidator
+ .BuildTwoFactorResultAsync(validatorContext.User, twoFactorOrganization);
+
+ // Include Master Password Policy in 2FA response
+ resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(validatorContext.User));
+ SetTwoFactorResult(context, resultDict);
+ }
+ else
+ {
+ await SendFailedTwoFactorEmail(validatorContext.User, twoFactorProviderType);
+ await UpdateFailedAuthDetailsAsync(validatorContext.User);
+ await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context,
+ validatorContext.User);
+ }
+
+ return false;
+ }
+
+ // 3c. Given a valid token and a successful two-factor verification, if the provider type is Recovery Code,
+ // recovery will have been performed as part of 2FA validation. This will be relevant for, e.g., SSO users
+ // who are requesting recovery, but who will still need to log in after 2FA recovery.
+ if (twoFactorProviderType == TwoFactorProviderType.RecoveryCode)
+ {
+ validatorContext.TwoFactorRecoveryRequested = true;
+ }
+
+ // 3d. When the 2FA authentication is successful, we can check if the user wants a
+ // rememberMe token.
+ var twoFactorRemember = request.Raw["TwoFactorRemember"] == "1";
+ // Check if the user wants a rememberMe token.
+ if (twoFactorRemember
+ // if the 2FA auth was rememberMe do not send another token.
+ && twoFactorProviderType != TwoFactorProviderType.Remember)
+ {
+ validatorContext.RememberMeRequested = true;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Validates whether the user is logging in from a known device.
+ ///
+ /// The current request context.
+ ///
+ ///
+ /// true if the scheme successfully passed validation, otherwise false.
+ private async Task ValidateNewDeviceAsync(T context, ValidatedTokenRequest request,
+ CustomValidatorRequestContext validatorContext)
+ {
+ var deviceValid = await _deviceValidator.ValidateRequestDeviceAsync(request, validatorContext);
+ if (deviceValid)
+ {
+ return true;
+ }
+
+ SetValidationErrorResult(context, validatorContext);
+ await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn);
+ return false;
+ }
+
+ ///
+ /// Validates whether the user should be denied access on a given non-Web client and sent to the Web client
+ /// for Legacy migration.
+ ///
+ /// The current request context.
+ ///
+ ///
+ /// true if the scheme successfully passed validation, otherwise false.
+ private async Task ValidateLegacyMigrationAsync(T context, ValidatedTokenRequest request,
+ CustomValidatorRequestContext validatorContext)
+ {
+ if (!UserService.IsLegacyUser(validatorContext.User) || request.ClientId == "web")
+ {
+ return true;
+ }
+
+ await FailAuthForLegacyUserAsync(validatorContext.User, context);
+ return false;
+ }
+
+ ///
+ /// Validates and updates the auth request's timestamp.
+ ///
+ ///
+ /// true on evaluation and/or completed update of the AuthRequest.
+ private async Task ValidateAuthRequestAsync(CustomValidatorRequestContext validatorContext)
+ {
+ // TODO: PM-24324 - This should be its own validator at some point.
+ if (validatorContext.ValidatedAuthRequest != null)
+ {
+ validatorContext.ValidatedAuthRequest.AuthenticationDate = DateTime.UtcNow;
+ await _authRequestRepository.ReplaceAsync(validatorContext.ValidatedAuthRequest);
+ }
+
+ return true;
+ }
///
/// Responsible for building the response to the client when the user has successfully authenticated.
@@ -256,7 +569,7 @@ public abstract class BaseRequestValidator where T : class
/// used to associate the failed login with a user
/// void
[Obsolete("Consider using SetValidationErrorResult to set the validation result, and LogFailedLoginEvent " +
- "to log the failure.")]
+ "to log the failure.")]
protected async Task BuildErrorResultAsync(string message, bool twoFactorRequest, T context, User user)
{
if (user != null)
@@ -268,7 +581,8 @@ public abstract class BaseRequestValidator where T : class
if (_globalSettings.SelfHosted)
{
_logger.LogWarning(Constants.BypassFiltersEventId,
- "Failed login attempt. Is2FARequest: {Is2FARequest} IpAddress: {IpAddress}", twoFactorRequest, CurrentContext.IpAddress);
+ "Failed login attempt. Is2FARequest: {Is2FARequest} IpAddress: {IpAddress}", twoFactorRequest,
+ CurrentContext.IpAddress);
}
await Task.Delay(2000); // Delay for brute force.
@@ -292,21 +606,26 @@ public abstract class BaseRequestValidator where T : class
formattedMessage = string.Format("Failed login attempt. {0}", $" {CurrentContext.IpAddress}");
break;
case EventType.User_FailedLogIn2fa:
- formattedMessage = string.Format("Failed login attempt, 2FA invalid.{0}", $" {CurrentContext.IpAddress}");
+ formattedMessage = string.Format("Failed login attempt, 2FA invalid.{0}",
+ $" {CurrentContext.IpAddress}");
break;
default:
formattedMessage = "Failed login attempt.";
break;
}
+
_logger.LogWarning(Constants.BypassFiltersEventId, "{FailedLoginMessage}", formattedMessage);
}
+
await Task.Delay(2000); // Delay for brute force.
}
[Obsolete("Consider using SetValidationErrorResult instead.")]
protected abstract void SetTwoFactorResult(T context, Dictionary customResponse);
+
[Obsolete("Consider using SetValidationErrorResult instead.")]
protected abstract void SetSsoResult(T context, Dictionary customResponse);
+
[Obsolete("Consider using SetValidationErrorResult instead.")]
protected abstract void SetErrorResult(T context, Dictionary customResponse);
@@ -317,6 +636,7 @@ public abstract class BaseRequestValidator where T : class
/// The current grant or token context
/// The modified request context containing material used to build the response object
protected abstract void SetValidationErrorResult(T context, CustomValidatorRequestContext requestContext);
+
protected abstract Task SetSuccessResult(T context, User user, List claims,
Dictionary customResponse);
@@ -343,7 +663,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)
? (await PolicyRequirementQuery.GetAsync(user.Id))
- .SsoRequired
+ .SsoRequired
: await PolicyService.AnyPoliciesApplicableToUserAsync(
user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
if (ssoRequired)
@@ -385,7 +705,8 @@ public abstract class BaseRequestValidator where T : class
{
if (FeatureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail))
{
- await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow, CurrentContext.IpAddress);
+ await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow,
+ CurrentContext.IpAddress);
}
}
@@ -416,16 +737,14 @@ public abstract class BaseRequestValidator where T : class
// We need this because we check for changes in the stamp to determine if we need to invalidate token refresh requests,
// in the `ProfileService.IsActiveAsync` method.
// If we don't store the security stamp in the persisted grant, we won't have the previous value to compare against.
- var claims = new List
- {
- new Claim(Claims.SecurityStamp, user.SecurityStamp)
- };
+ var claims = new List { new Claim(Claims.SecurityStamp, user.SecurityStamp) };
if (device != null)
{
claims.Add(new Claim(Claims.Device, device.Identifier));
claims.Add(new Claim(Claims.DeviceType, device.Type.ToString()));
}
+
return claims;
}
@@ -437,7 +756,8 @@ public abstract class BaseRequestValidator where T : class
/// The current request context.
/// The device used for authentication.
/// Whether to send a 2FA remember token.
- private async Task> BuildCustomResponse(User user, T context, Device device, bool sendRememberToken)
+ private async Task> BuildCustomResponse(User user, T context, Device device,
+ bool sendRememberToken)
{
var customResponse = new Dictionary();
if (!string.IsNullOrWhiteSpace(user.PrivateKey))
@@ -459,7 +779,8 @@ public abstract class BaseRequestValidator where T : class
customResponse.Add("KdfIterations", user.KdfIterations);
customResponse.Add("KdfMemory", user.KdfMemory);
customResponse.Add("KdfParallelism", user.KdfParallelism);
- customResponse.Add("UserDecryptionOptions", await CreateUserDecryptionOptionsAsync(user, device, GetSubject(context)));
+ customResponse.Add("UserDecryptionOptions",
+ await CreateUserDecryptionOptionsAsync(user, device, GetSubject(context)));
if (sendRememberToken)
{
@@ -467,6 +788,7 @@ public abstract class BaseRequestValidator where T : class
CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember));
customResponse.Add("TwoFactorToken", token);
}
+
return customResponse;
}
@@ -474,7 +796,8 @@ public abstract class BaseRequestValidator where T : class
///
/// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents
///
- private async Task CreateUserDecryptionOptionsAsync(User user, Device device, ClaimsPrincipal subject)
+ private async Task CreateUserDecryptionOptionsAsync(User user, Device device,
+ ClaimsPrincipal subject)
{
var ssoConfig = await GetSsoConfigurationDataAsync(subject);
return await UserDecryptionOptionsBuilder
diff --git a/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs b/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs
index 5ee3bda956..3063524a57 100644
--- a/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs
+++ b/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs
@@ -1,6 +1,7 @@
using System.Reflection;
using AutoFixture;
using AutoFixture.Xunit2;
+using Bit.Identity.IdentityServer;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.Test.AutoFixture;
@@ -8,7 +9,8 @@ namespace Bit.Identity.Test.AutoFixture;
internal class ValidatedTokenRequestCustomization : ICustomization
{
public ValidatedTokenRequestCustomization()
- { }
+ {
+ }
public void Customize(IFixture fixture)
{
@@ -22,10 +24,45 @@ internal class ValidatedTokenRequestCustomization : ICustomization
public class ValidatedTokenRequestAttribute : CustomizeAttribute
{
public ValidatedTokenRequestAttribute()
- { }
+ {
+ }
public override ICustomization GetCustomization(ParameterInfo parameter)
{
return new ValidatedTokenRequestCustomization();
}
}
+
+internal class CustomValidatorRequestContextCustomization : ICustomization
+{
+ public CustomValidatorRequestContextCustomization()
+ {
+ }
+
+ ///
+ /// Specific context members like ,
+ /// , 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
+ ///
+ public void Customize(IFixture fixture)
+ {
+ fixture.Customize(composer => composer
+ .With(o => o.RememberMeRequested, false)
+ .With(o => o.TwoFactorRecoveryRequested, false)
+ .With(o => o.SsoRequired, false));
+ }
+}
+
+public class CustomValidatorRequestContextAttribute : CustomizeAttribute
+{
+ public CustomValidatorRequestContextAttribute()
+ {
+ }
+
+ public override ICustomization GetCustomization(ParameterInfo parameter)
+ {
+ return new CustomValidatorRequestContextCustomization();
+ }
+}
diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs
index 53615cd1d1..e78c7d161c 100644
--- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs
+++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs
@@ -100,19 +100,30 @@ public class BaseRequestValidatorTests
_userAccountKeysQuery);
}
+ private void SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(bool recoveryCodeSupportEnabled)
+ {
+ _featureService
+ .IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers)
+ .Returns(recoveryCodeSupportEnabled);
+ }
+
/* Logic path
* ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync
* |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
* (self hosted) |-> _logger.LogWarning()
* |-> SetErrorResult
*/
- [Theory, BitAutoData]
+ [Theory]
+ [BitAutoData(true)]
+ [BitAutoData(false)]
public async Task ValidateAsync_ContextNotValid_SelfHosted_ShouldBuildErrorResult_ShouldLogWarning(
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
_globalSettings.SelfHosted = true;
_sut.isValid = false;
@@ -122,18 +133,23 @@ public class BaseRequestValidatorTests
// Assert
var logs = _logger.Collector.GetSnapshot(true);
- Assert.Contains(logs, l => l.Level == LogLevel.Warning && l.Message == "Failed login attempt. Is2FARequest: False IpAddress: ");
+ Assert.Contains(logs,
+ l => l.Level == LogLevel.Warning && l.Message == "Failed login attempt. Is2FARequest: False IpAddress: ");
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message);
}
- [Theory, BitAutoData]
+ [Theory]
+ [BitAutoData(true)]
+ [BitAutoData(false)]
public async Task ValidateAsync_DeviceNotValidated_ShouldLogError(
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass
_sut.isValid = true;
@@ -141,14 +157,15 @@ public class BaseRequestValidatorTests
// 2 -> will result to false with no extra configuration
// 3 -> set two factor to be false
_twoFactorAuthenticationValidator
- .RequiresTwoFactorAsync(Arg.Any(), tokenRequest)
- .Returns(Task.FromResult(new Tuple(false, null)));
+ .RequiresTwoFactorAsync(Arg.Any(), tokenRequest)
+ .Returns(Task.FromResult(new Tuple(false, null)));
// 4 -> set up device validator to fail
requestContext.KnownDevice = false;
tokenRequest.GrantType = "password";
- _deviceValidator.ValidateRequestDeviceAsync(Arg.Any(), Arg.Any())
- .Returns(Task.FromResult(false));
+ _deviceValidator
+ .ValidateRequestDeviceAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(false));
// 5 -> not legacy user
_userService.IsLegacyUser(Arg.Any())
@@ -163,13 +180,17 @@ public class BaseRequestValidatorTests
.LogUserEventAsync(context.CustomValidatorRequestContext.User.Id, EventType.User_FailedLogIn);
}
- [Theory, BitAutoData]
+ [Theory]
+ [BitAutoData(true)]
+ [BitAutoData(false)]
public async Task ValidateAsync_DeviceValidated_ShouldSucceed(
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass
_sut.isValid = true;
@@ -177,12 +198,13 @@ public class BaseRequestValidatorTests
// 2 -> will result to false with no extra configuration
// 3 -> set two factor to be false
_twoFactorAuthenticationValidator
- .RequiresTwoFactorAsync(Arg.Any(), tokenRequest)
- .Returns(Task.FromResult(new Tuple(false, null)));
+ .RequiresTwoFactorAsync(Arg.Any(), tokenRequest)
+ .Returns(Task.FromResult(new Tuple(false, null)));
// 4 -> set up device validator to pass
- _deviceValidator.ValidateRequestDeviceAsync(Arg.Any(), Arg.Any())
- .Returns(Task.FromResult(true));
+ _deviceValidator
+ .ValidateRequestDeviceAsync(Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(true));
// 5 -> not legacy user
_userService.IsLegacyUser(Arg.Any())
@@ -202,13 +224,17 @@ public class BaseRequestValidatorTests
Assert.False(context.GrantResult.IsError);
}
- [Theory, BitAutoData]
+ [Theory]
+ [BitAutoData(true)]
+ [BitAutoData(false)]
public async Task ValidateAsync_ValidatedAuthRequest_ConsumedOnSuccess(
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass
_sut.isValid = true;
@@ -235,7 +261,8 @@ public class BaseRequestValidatorTests
.Returns(Task.FromResult(new Tuple(false, null)));
// 4 -> set up device validator to pass
- _deviceValidator.ValidateRequestDeviceAsync(Arg.Any(), Arg.Any())
+ _deviceValidator
+ .ValidateRequestDeviceAsync(Arg.Any(), Arg.Any())
.Returns(Task.FromResult(true));
// 5 -> not legacy user
@@ -260,13 +287,17 @@ public class BaseRequestValidatorTests
ar.AuthenticationDate.HasValue));
}
- [Theory, BitAutoData]
+ [Theory]
+ [BitAutoData(true)]
+ [BitAutoData(false)]
public async Task ValidateAsync_ValidatedAuthRequest_NotConsumed_When2faRequired(
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass
_sut.isValid = true;
@@ -302,13 +333,17 @@ public class BaseRequestValidatorTests
await _authRequestRepository.DidNotReceive().ReplaceAsync(Arg.Any());
}
- [Theory, BitAutoData]
+ [Theory]
+ [BitAutoData(true)]
+ [BitAutoData(false)]
public async Task ValidateAsync_TwoFactorTokenInvalid_ShouldSendFailedTwoFactorEmail(
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
var user = requestContext.User;
@@ -345,13 +380,17 @@ public class BaseRequestValidatorTests
Arg.Any());
}
- [Theory, BitAutoData]
+ [Theory]
+ [BitAutoData(true)]
+ [BitAutoData(false)]
public async Task ValidateAsync_TwoFactorRememberTokenExpired_ShouldNotSendFailedTwoFactorEmail(
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
var user = requestContext.User;
@@ -391,28 +430,34 @@ public class BaseRequestValidatorTests
// Assert
// Verify that the failed 2FA email was NOT sent for remember token expiration
await _mailService.DidNotReceive()
- .SendFailedTwoFactorAttemptEmailAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any());
+ .SendFailedTwoFactorAttemptEmailAsync(Arg.Any(), Arg.Any(),
+ Arg.Any(), Arg.Any());
}
// Test grantTypes that require SSO when a user is in an organization that requires it
[Theory]
- [BitAutoData("password")]
- [BitAutoData("webauthn")]
- [BitAutoData("refresh_token")]
+ [BitAutoData("password", true)]
+ [BitAutoData("password", false)]
+ [BitAutoData("webauthn", true)]
+ [BitAutoData("webauthn", false)]
+ [BitAutoData("refresh_token", true)]
+ [BitAutoData("refresh_token", false)]
public async Task ValidateAsync_GrantTypes_OrgSsoRequiredTrue_ShouldSetSsoResult(
string grantType,
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
context.ValidatedTokenRequest.GrantType = grantType;
_policyService.AnyPoliciesApplicableToUserAsync(
- Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
- .Returns(Task.FromResult(true));
+ Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
+ .Returns(Task.FromResult(true));
// Act
await _sut.ValidateAsync(context);
@@ -425,16 +470,21 @@ public class BaseRequestValidatorTests
// Test grantTypes with RequireSsoPolicyRequirement when feature flag is enabled
[Theory]
- [BitAutoData("password")]
- [BitAutoData("webauthn")]
- [BitAutoData("refresh_token")]
+ [BitAutoData("password", true)]
+ [BitAutoData("password", false)]
+ [BitAutoData("webauthn", true)]
+ [BitAutoData("webauthn", false)]
+ [BitAutoData("refresh_token", true)]
+ [BitAutoData("refresh_token", false)]
public async Task ValidateAsync_GrantTypes_WithPolicyRequirementsEnabled_OrgSsoRequiredTrue_ShouldSetSsoResult(
string grantType,
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
@@ -449,23 +499,28 @@ public class BaseRequestValidatorTests
// Assert
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
- Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
+ Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
Assert.True(context.GrantResult.IsError);
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
Assert.Equal("SSO authentication is required.", errorResponse.Message);
}
[Theory]
- [BitAutoData("password")]
- [BitAutoData("webauthn")]
- [BitAutoData("refresh_token")]
+ [BitAutoData("password", true)]
+ [BitAutoData("password", false)]
+ [BitAutoData("webauthn", true)]
+ [BitAutoData("webauthn", false)]
+ [BitAutoData("refresh_token", true)]
+ [BitAutoData("refresh_token", false)]
public async Task ValidateAsync_GrantTypes_WithPolicyRequirementsEnabled_OrgSsoRequiredFalse_ShouldSucceed(
string grantType,
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
@@ -500,24 +555,29 @@ public class BaseRequestValidatorTests
// Test grantTypes where SSO would be required but the user is not in an
// organization that requires it
[Theory]
- [BitAutoData("password")]
- [BitAutoData("webauthn")]
- [BitAutoData("refresh_token")]
+ [BitAutoData("password", true)]
+ [BitAutoData("password", false)]
+ [BitAutoData("webauthn", true)]
+ [BitAutoData("webauthn", false)]
+ [BitAutoData("refresh_token", true)]
+ [BitAutoData("refresh_token", false)]
public async Task ValidateAsync_GrantTypes_OrgSsoRequiredFalse_ShouldSucceed(
string grantType,
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
context.ValidatedTokenRequest.GrantType = grantType;
_policyService.AnyPoliciesApplicableToUserAsync(
- Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
- .Returns(Task.FromResult(false));
+ Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
+ .Returns(Task.FromResult(false));
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
.Returns(Task.FromResult(new Tuple(false, null)));
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
@@ -540,20 +600,23 @@ public class BaseRequestValidatorTests
await _userRepository.Received(1).ReplaceAsync(Arg.Any());
Assert.False(context.GrantResult.IsError);
-
}
// Test the grantTypes where SSO is in progress or not relevant
[Theory]
- [BitAutoData("authorization_code")]
- [BitAutoData("client_credentials")]
+ [BitAutoData("authorization_code", true)]
+ [BitAutoData("authorization_code", false)]
+ [BitAutoData("client_credentials", true)]
+ [BitAutoData("client_credentials", false)]
public async Task ValidateAsync_GrantTypes_SsoRequiredFalse_ShouldSucceed(
string grantType,
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
@@ -577,7 +640,7 @@ public class BaseRequestValidatorTests
// Assert
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
- Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
+ Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
await _eventService.Received(1).LogUserEventAsync(
context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn);
await _userRepository.Received(1).ReplaceAsync(Arg.Any());
@@ -588,13 +651,17 @@ public class BaseRequestValidatorTests
/* Logic Path
* ValidateAsync -> UserService.IsLegacyUser -> FailAuthForLegacyUserAsync
*/
- [Theory, BitAutoData]
+ [Theory]
+ [BitAutoData(true)]
+ [BitAutoData(false)]
public async Task ValidateAsync_IsLegacyUser_FailAuthForLegacyUserAsync(
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
var user = context.CustomValidatorRequestContext.User;
user.Key = null;
@@ -613,21 +680,27 @@ public class BaseRequestValidatorTests
// Assert
Assert.True(context.GrantResult.IsError);
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
- var expectedMessage = "Legacy encryption without a userkey is no longer supported. To recover your account, please contact support";
+ var expectedMessage =
+ "Legacy encryption without a userkey is no longer supported. To recover your account, please contact support";
Assert.Equal(expectedMessage, errorResponse.Message);
}
- [Theory, BitAutoData]
+ [Theory]
+ [BitAutoData(true)]
+ [BitAutoData(false)]
public async Task ValidateAsync_CustomResponse_NoMasterPassword_ShouldSetUserDecryptionOptions(
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
_userDecryptionOptionsBuilder.ForUser(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.WithDevice(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.WithSso(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
- _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
+ _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any())
+ .Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions
{
HasMasterPassword = false,
@@ -663,19 +736,24 @@ public class BaseRequestValidatorTests
}
[Theory]
- [BitAutoData(KdfType.PBKDF2_SHA256, 654_321, null, null)]
- [BitAutoData(KdfType.Argon2id, 11, 128, 5)]
+ [BitAutoData(true, KdfType.PBKDF2_SHA256, 654_321, null, null)]
+ [BitAutoData(false, KdfType.PBKDF2_SHA256, 654_321, null, null)]
+ [BitAutoData(true, KdfType.Argon2id, 11, 128, 5)]
+ [BitAutoData(false, KdfType.Argon2id, 11, 128, 5)]
public async Task ValidateAsync_CustomResponse_MasterPassword_ShouldSetUserDecryptionOptions(
+ bool featureFlagValue,
KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
_userDecryptionOptionsBuilder.ForUser(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.WithDevice(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.WithSso(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
- _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
+ _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any())
+ .Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions
{
HasMasterPassword = true,
@@ -728,13 +806,17 @@ public class BaseRequestValidatorTests
Assert.Equal("test@example.com", userDecryptionOptions.MasterPasswordUnlock.Salt);
}
- [Theory, BitAutoData]
+ [Theory]
+ [BitAutoData(true)]
+ [BitAutoData(false)]
public async Task ValidateAsync_CustomResponse_ShouldIncludeAccountKeys(
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var mockAccountKeys = new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
@@ -747,11 +829,7 @@ public class BaseRequestValidatorTests
"test-wrapped-signing-key",
"test-verifying-key"
),
- SecurityStateData = new SecurityStateData
- {
- SecurityState = "test-security-state",
- SecurityVersion = 2
- }
+ SecurityStateData = new SecurityStateData { SecurityState = "test-security-state", SecurityVersion = 2 }
};
_userAccountKeysQuery.Run(Arg.Any()).Returns(mockAccountKeys);
@@ -759,7 +837,8 @@ public class BaseRequestValidatorTests
_userDecryptionOptionsBuilder.ForUser(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.WithDevice(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.WithSso(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
- _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
+ _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any())
+ .Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions
{
HasMasterPassword = true,
@@ -808,13 +887,18 @@ public class BaseRequestValidatorTests
Assert.Equal("test-security-state", accountKeysResponse.SecurityState.SecurityState);
Assert.Equal(2, accountKeysResponse.SecurityState.SecurityVersion);
}
- [Theory, BitAutoData]
+
+ [Theory]
+ [BitAutoData(true)]
+ [BitAutoData(false)]
public async Task ValidateAsync_CustomResponse_AccountKeysQuery_SkippedWhenPrivateKeyIsNull(
- [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
- GrantValidationResult grantResult)
+ bool featureFlagValue,
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
requestContext.User.PrivateKey = null;
var context = CreateContext(tokenRequest, requestContext, grantResult);
@@ -833,13 +917,18 @@ public class BaseRequestValidatorTests
// Verify that the account keys query wasn't called.
await _userAccountKeysQuery.Received(0).Run(Arg.Any());
}
- [Theory, BitAutoData]
+
+ [Theory]
+ [BitAutoData(true)]
+ [BitAutoData(false)]
public async Task ValidateAsync_CustomResponse_AccountKeysQuery_CalledWithCorrectUser(
+ bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
- CustomValidatorRequestContext requestContext,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var expectedUser = requestContext.User;
_userAccountKeysQuery.Run(Arg.Any()).Returns(new UserAccountKeysData
@@ -853,7 +942,8 @@ public class BaseRequestValidatorTests
_userDecryptionOptionsBuilder.ForUser(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.WithDevice(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.WithSso(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
- _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any()).Returns(_userDecryptionOptionsBuilder);
+ _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any())
+ .Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions()));
var context = CreateContext(tokenRequest, requestContext, grantResult);
@@ -874,6 +964,285 @@ public class BaseRequestValidatorTests
await _userAccountKeysQuery.Received(1).Run(Arg.Is(u => u.Id == expectedUser.Id));
}
+ ///
+ /// Tests the core PM-21153 feature: SSO-required users can use recovery codes to disable 2FA,
+ /// but must then authenticate via SSO with a descriptive message about the recovery.
+ /// This test validates:
+ /// 1. Validation order is changed (2FA before SSO) when recovery code is provided
+ /// 2. Recovery code successfully validates and sets TwoFactorRecoveryRequested flag
+ /// 3. SSO validation then fails with recovery-specific message
+ /// 4. User is NOT logged in (must authenticate via IdP)
+ ///
+ [Theory]
+ [BitAutoData(true)] // Feature flag ON - new behavior
+ [BitAutoData(false)] // Feature flag OFF - should fail at SSO before 2FA recovery
+ public async Task ValidateAsync_RecoveryCodeForSsoRequiredUser_BlocksWithDescriptiveMessage(
+ bool featureFlagEnabled,
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ // Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled);
+ var context = CreateContext(tokenRequest, requestContext, grantResult);
+ var user = requestContext.User;
+
+ // Reset state that AutoFixture may have populated
+ requestContext.TwoFactorRecoveryRequested = false;
+ requestContext.RememberMeRequested = false;
+
+ // 1. Master password is valid
+ _sut.isValid = true;
+
+ // 2. SSO is required (this user is in an org that requires SSO)
+ _policyService.AnyPoliciesApplicableToUserAsync(
+ Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
+ .Returns(Task.FromResult(true));
+
+ // 3. 2FA is required
+ _twoFactorAuthenticationValidator
+ .RequiresTwoFactorAsync(user, tokenRequest)
+ .Returns(Task.FromResult(new Tuple(true, null)));
+
+ // 4. Provide a RECOVERY CODE (this triggers the special validation order)
+ tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString();
+ tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code-12345";
+
+ // 5. Recovery code is valid (UserService.RecoverTwoFactorAsync will be called internally)
+ _twoFactorAuthenticationValidator
+ .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code-12345")
+ .Returns(Task.FromResult(true));
+
+ // Act
+ await _sut.ValidateAsync(context);
+
+ // Assert
+ Assert.True(context.GrantResult.IsError, "Authentication should fail - SSO required after recovery");
+
+ var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
+
+ if (featureFlagEnabled)
+ {
+ // NEW BEHAVIOR: Recovery succeeds, then SSO blocks with descriptive message
+ Assert.Equal(
+ "Two-factor recovery has been performed. SSO authentication is required.",
+ errorResponse.Message);
+
+ // Verify recovery was marked
+ Assert.True(requestContext.TwoFactorRecoveryRequested,
+ "TwoFactorRecoveryRequested flag should be set");
+ }
+ else
+ {
+ // LEGACY BEHAVIOR: SSO blocks BEFORE recovery can happen
+ Assert.Equal(
+ "SSO authentication is required.",
+ errorResponse.Message);
+
+ // Recovery never happened because SSO checked first
+ Assert.False(requestContext.TwoFactorRecoveryRequested,
+ "TwoFactorRecoveryRequested should be false (SSO blocked first)");
+ }
+
+ // In both cases: User is NOT logged in
+ await _eventService.DidNotReceive().LogUserEventAsync(user.Id, EventType.User_LoggedIn);
+ }
+
+ ///
+ /// Tests that validation order changes when a recovery code is PROVIDED (even if invalid).
+ /// This ensures the RecoveryCodeRequestForSsoRequiredUserScenario() logic is based on
+ /// request structure, not validation outcome. An SSO-required user who provides an
+ /// INVALID recovery code should:
+ /// 1. Have 2FA validated BEFORE SSO (new order)
+ /// 2. Get a 2FA error (invalid token)
+ /// 3. NOT get the recovery-specific SSO message (because recovery didn't complete)
+ /// 4. NOT be logged in
+ ///
+ [Theory]
+ [BitAutoData(true)]
+ [BitAutoData(false)]
+ public async Task ValidateAsync_InvalidRecoveryCodeForSsoRequiredUser_FailsAt2FA(
+ bool featureFlagEnabled,
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ // Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled);
+ var context = CreateContext(tokenRequest, requestContext, grantResult);
+ var user = requestContext.User;
+
+ // 1. Master password is valid
+ _sut.isValid = true;
+
+ // 2. SSO is required
+ _policyService.AnyPoliciesApplicableToUserAsync(
+ Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
+ .Returns(Task.FromResult(true));
+
+ // 3. 2FA is required
+ _twoFactorAuthenticationValidator
+ .RequiresTwoFactorAsync(user, tokenRequest)
+ .Returns(Task.FromResult(new Tuple(true, null)));
+
+ // 4. Provide a RECOVERY CODE (triggers validation order change)
+ tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString();
+ tokenRequest.Raw["TwoFactorToken"] = "INVALID-recovery-code";
+
+ // 5. Recovery code is INVALID
+ _twoFactorAuthenticationValidator
+ .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "INVALID-recovery-code")
+ .Returns(Task.FromResult(false));
+
+ // 6. Setup for failed 2FA email (if feature flag enabled)
+ _featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail).Returns(true);
+
+ // Act
+ await _sut.ValidateAsync(context);
+
+ // Assert
+ Assert.True(context.GrantResult.IsError, "Authentication should fail - invalid recovery code");
+
+ var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
+
+ if (featureFlagEnabled)
+ {
+ // NEW BEHAVIOR: 2FA is checked first (due to recovery code request), fails with 2FA error
+ Assert.Equal(
+ "Two-step token is invalid. Try again.",
+ errorResponse.Message);
+
+ // Recovery was attempted but failed - flag should NOT be set
+ Assert.False(requestContext.TwoFactorRecoveryRequested,
+ "TwoFactorRecoveryRequested should be false (recovery failed)");
+
+ // Verify failed 2FA email was sent
+ await _mailService.Received(1).SendFailedTwoFactorAttemptEmailAsync(
+ user.Email,
+ TwoFactorProviderType.RecoveryCode,
+ Arg.Any(),
+ Arg.Any());
+
+ // Verify failed login event was logged
+ await _eventService.Received(1).LogUserEventAsync(user.Id, EventType.User_FailedLogIn2fa);
+ }
+ else
+ {
+ // LEGACY BEHAVIOR: SSO is checked first, blocks before 2FA
+ Assert.Equal(
+ "SSO authentication is required.",
+ errorResponse.Message);
+
+ // 2FA validation never happened
+ await _mailService.DidNotReceive().SendFailedTwoFactorAttemptEmailAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any());
+ }
+
+ // In both cases: User is NOT logged in
+ await _eventService.DidNotReceive().LogUserEventAsync(user.Id, EventType.User_LoggedIn);
+
+ // Verify user failed login count was updated (in new behavior path)
+ if (featureFlagEnabled)
+ {
+ await _userRepository.Received(1).ReplaceAsync(Arg.Is(u =>
+ u.Id == user.Id && u.FailedLoginCount > 0));
+ }
+ }
+
+ ///
+ /// Tests that non-SSO users can successfully use recovery codes to disable 2FA and log in.
+ /// This validates:
+ /// 1. Validation order changes to 2FA-first when recovery code is provided
+ /// 2. Recovery code validates successfully
+ /// 3. SSO check passes (user not in SSO-required org)
+ /// 4. User successfully logs in
+ /// 5. TwoFactorRecoveryRequested flag is set (for logging/audit purposes)
+ /// This is the "happy path" for recovery code usage.
+ ///
+ [Theory]
+ [BitAutoData(true)]
+ [BitAutoData(false)]
+ public async Task ValidateAsync_RecoveryCodeForNonSsoUser_SuccessfulLogin(
+ bool featureFlagEnabled,
+ [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
+ [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
+ GrantValidationResult grantResult)
+ {
+ // Arrange
+ SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled);
+ var context = CreateContext(tokenRequest, requestContext, grantResult);
+ var user = requestContext.User;
+
+ // 1. Master password is valid
+ _sut.isValid = true;
+
+ // 2. SSO is NOT required (this is a regular user, not in SSO org)
+ _policyService.AnyPoliciesApplicableToUserAsync(
+ Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
+ .Returns(Task.FromResult(false));
+
+ // 3. 2FA is required
+ _twoFactorAuthenticationValidator
+ .RequiresTwoFactorAsync(user, tokenRequest)
+ .Returns(Task.FromResult(new Tuple(true, null)));
+
+ // 4. Provide a RECOVERY CODE
+ tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString();
+ tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code-67890";
+
+ // 5. Recovery code is valid
+ _twoFactorAuthenticationValidator
+ .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code-67890")
+ .Returns(Task.FromResult(true));
+
+ // 6. Device validation passes
+ _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
+ .Returns(Task.FromResult(true));
+
+ // 7. User is not legacy
+ _userService.IsLegacyUser(Arg.Any())
+ .Returns(false);
+
+ // 8. Setup user account keys for successful login response
+ _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, "Authentication should succeed for non-SSO user with valid recovery code");
+
+ // Verify user successfully logged in
+ await _eventService.Received(1).LogUserEventAsync(user.Id, EventType.User_LoggedIn);
+
+ // Verify failed login count was reset (successful login)
+ await _userRepository.Received(1).ReplaceAsync(Arg.Is(u =>
+ u.Id == user.Id && u.FailedLoginCount == 0));
+
+ if (featureFlagEnabled)
+ {
+ // NEW BEHAVIOR: Recovery flag should be set for audit purposes
+ Assert.True(requestContext.TwoFactorRecoveryRequested,
+ "TwoFactorRecoveryRequested flag should be set for audit/logging");
+ }
+ else
+ {
+ // LEGACY BEHAVIOR: Recovery flag doesn't exist, but login still succeeds
+ // (SSO check happens before 2FA in legacy, but user is not SSO-required so both pass)
+ Assert.False(requestContext.TwoFactorRecoveryRequested,
+ "TwoFactorRecoveryRequested should be false in legacy mode");
+ }
+ }
+
private BaseRequestValidationContextFake CreateContext(
ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,