@ -100,19 +100,30 @@ public class BaseRequestValidatorTests
@@ -100,19 +100,30 @@ public class BaseRequestValidatorTests
_ userAccountKeysQuery ) ;
}
private void SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag ( bool recoveryCodeSupportEnabled )
{
_f eatureService
. IsEnabled ( FeatureFlagKeys . RecoveryCodeSupportForSsoRequiredUsers )
. Returns ( recoveryCodeSupportEnabled ) ;
}
/ * Logic path
* ValidateAsync - > UpdateFailedAuthDetailsAsync - > _ mailService . SendFailedLoginAttemptsEmailAsync
* | - > BuildErrorResultAsync - > _ eventService . LogUserEventAsync
* ( self hosted ) | - > _l ogger . 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
@@ -122,18 +133,23 @@ public class BaseRequestValidatorTests
// Assert
var logs = _l ogger . 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
@@ -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 < User > ( ) , tokenRequest )
. Returns ( Task . FromResult ( new Tuple < bool , Organization > ( false , null ) ) ) ;
. RequiresTwoFactorAsync ( Arg . Any < User > ( ) , tokenRequest )
. Returns ( Task . FromResult ( new Tuple < bool , Organization > ( false , null ) ) ) ;
// 4 -> set up device validator to fail
requestContext . KnownDevice = false ;
tokenRequest . GrantType = "password" ;
_d eviceValidator . ValidateRequestDeviceAsync ( Arg . Any < ValidatedTokenRequest > ( ) , Arg . Any < CustomValidatorRequestContext > ( ) )
. Returns ( Task . FromResult ( false ) ) ;
_d eviceValidator
. ValidateRequestDeviceAsync ( Arg . Any < ValidatedTokenRequest > ( ) , Arg . Any < CustomValidatorRequestContext > ( ) )
. Returns ( Task . FromResult ( false ) ) ;
// 5 -> not legacy user
_ userService . IsLegacyUser ( Arg . Any < string > ( ) )
@ -163,13 +180,17 @@ public class BaseRequestValidatorTests
@@ -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
@@ -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 < User > ( ) , tokenRequest )
. Returns ( Task . FromResult ( new Tuple < bool , Organization > ( false , null ) ) ) ;
. RequiresTwoFactorAsync ( Arg . Any < User > ( ) , tokenRequest )
. Returns ( Task . FromResult ( new Tuple < bool , Organization > ( false , null ) ) ) ;
// 4 -> set up device validator to pass
_d eviceValidator . ValidateRequestDeviceAsync ( Arg . Any < ValidatedTokenRequest > ( ) , Arg . Any < CustomValidatorRequestContext > ( ) )
. Returns ( Task . FromResult ( true ) ) ;
_d eviceValidator
. ValidateRequestDeviceAsync ( Arg . Any < ValidatedTokenRequest > ( ) , Arg . Any < CustomValidatorRequestContext > ( ) )
. Returns ( Task . FromResult ( true ) ) ;
// 5 -> not legacy user
_ userService . IsLegacyUser ( Arg . Any < string > ( ) )
@ -202,13 +224,17 @@ public class BaseRequestValidatorTests
@@ -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
@@ -235,7 +261,8 @@ public class BaseRequestValidatorTests
. Returns ( Task . FromResult ( new Tuple < bool , Organization > ( false , null ) ) ) ;
// 4 -> set up device validator to pass
_d eviceValidator . ValidateRequestDeviceAsync ( Arg . Any < ValidatedTokenRequest > ( ) , Arg . Any < CustomValidatorRequestContext > ( ) )
_d eviceValidator
. ValidateRequestDeviceAsync ( Arg . Any < ValidatedTokenRequest > ( ) , Arg . Any < CustomValidatorRequestContext > ( ) )
. Returns ( Task . FromResult ( true ) ) ;
// 5 -> not legacy user
@ -260,13 +287,17 @@ public class BaseRequestValidatorTests
@@ -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
@@ -302,13 +333,17 @@ public class BaseRequestValidatorTests
await _ authRequestRepository . DidNotReceive ( ) . ReplaceAsync ( Arg . Any < AuthRequest > ( ) ) ;
}
[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
@@ -345,13 +380,17 @@ public class BaseRequestValidatorTests
Arg . Any < string > ( ) ) ;
}
[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
@@ -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 < string > ( ) , Arg . Any < TwoFactorProviderType > ( ) , Arg . Any < DateTime > ( ) , Arg . Any < string > ( ) ) ;
. SendFailedTwoFactorAttemptEmailAsync ( Arg . Any < string > ( ) , Arg . Any < TwoFactorProviderType > ( ) ,
Arg . Any < DateTime > ( ) , Arg . Any < string > ( ) ) ;
}
// 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 < Guid > ( ) , PolicyType . RequireSso , OrganizationUserStatusType . Confirmed )
. Returns ( Task . FromResult ( true ) ) ;
Arg . Any < Guid > ( ) , PolicyType . RequireSso , OrganizationUserStatusType . Confirmed )
. Returns ( Task . FromResult ( true ) ) ;
// Act
await _ sut . ValidateAsync ( context ) ;
@ -425,16 +470,21 @@ public class BaseRequestValidatorTests
@@ -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 ) ;
_f eatureService . IsEnabled ( FeatureFlagKeys . PolicyRequirements ) . Returns ( true ) ;
var context = CreateContext ( tokenRequest , requestContext , grantResult ) ;
_ sut . isValid = true ;
@ -449,23 +499,28 @@ public class BaseRequestValidatorTests
@@ -449,23 +499,28 @@ public class BaseRequestValidatorTests
// Assert
await _ policyService . DidNotReceive ( ) . AnyPoliciesApplicableToUserAsync (
Arg . Any < Guid > ( ) , PolicyType . RequireSso , OrganizationUserStatusType . Confirmed ) ;
Arg . Any < Guid > ( ) , 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 ) ;
_f eatureService . IsEnabled ( FeatureFlagKeys . PolicyRequirements ) . Returns ( true ) ;
var context = CreateContext ( tokenRequest , requestContext , grantResult ) ;
_ sut . isValid = true ;
@ -500,24 +555,29 @@ public class BaseRequestValidatorTests
@@ -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 < Guid > ( ) , PolicyType . RequireSso , OrganizationUserStatusType . Confirmed )
. Returns ( Task . FromResult ( false ) ) ;
Arg . Any < Guid > ( ) , PolicyType . RequireSso , OrganizationUserStatusType . Confirmed )
. Returns ( Task . FromResult ( false ) ) ;
_ twoFactorAuthenticationValidator . RequiresTwoFactorAsync ( requestContext . User , tokenRequest )
. Returns ( Task . FromResult ( new Tuple < bool , Organization > ( false , null ) ) ) ;
_d eviceValidator . ValidateRequestDeviceAsync ( tokenRequest , requestContext )
@ -540,20 +600,23 @@ public class BaseRequestValidatorTests
@@ -540,20 +600,23 @@ public class BaseRequestValidatorTests
await _ userRepository . Received ( 1 ) . ReplaceAsync ( Arg . Any < User > ( ) ) ;
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
@@ -577,7 +640,7 @@ public class BaseRequestValidatorTests
// Assert
await _ policyService . DidNotReceive ( ) . AnyPoliciesApplicableToUserAsync (
Arg . Any < Guid > ( ) , PolicyType . RequireSso , OrganizationUserStatusType . Confirmed ) ;
Arg . Any < Guid > ( ) , PolicyType . RequireSso , OrganizationUserStatusType . Confirmed ) ;
await _ eventService . Received ( 1 ) . LogUserEventAsync (
context . CustomValidatorRequestContext . User . Id , EventType . User_LoggedIn ) ;
await _ userRepository . Received ( 1 ) . ReplaceAsync ( Arg . Any < User > ( ) ) ;
@ -588,13 +651,17 @@ public class BaseRequestValidatorTests
@@ -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
@@ -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 < User > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithDevice ( Arg . Any < Device > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithSso ( Arg . Any < SsoConfig > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithWebAuthnLoginCredential ( Arg . Any < WebAuthnCredential > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithWebAuthnLoginCredential ( Arg . Any < WebAuthnCredential > ( ) )
. Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . BuildAsync ( ) . Returns ( Task . FromResult ( new UserDecryptionOptions
{
HasMasterPassword = false ,
@ -663,19 +736,24 @@ public class BaseRequestValidatorTests
@@ -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 < User > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithDevice ( Arg . Any < Device > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithSso ( Arg . Any < SsoConfig > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithWebAuthnLoginCredential ( Arg . Any < WebAuthnCredential > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithWebAuthnLoginCredential ( Arg . Any < WebAuthnCredential > ( ) )
. Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . BuildAsync ( ) . Returns ( Task . FromResult ( new UserDecryptionOptions
{
HasMasterPassword = true ,
@ -728,13 +806,17 @@ public class BaseRequestValidatorTests
@@ -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
@@ -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 < User > ( ) ) . Returns ( mockAccountKeys ) ;
@ -759,7 +837,8 @@ public class BaseRequestValidatorTests
@@ -759,7 +837,8 @@ public class BaseRequestValidatorTests
_ userDecryptionOptionsBuilder . ForUser ( Arg . Any < User > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithDevice ( Arg . Any < Device > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithSso ( Arg . Any < SsoConfig > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithWebAuthnLoginCredential ( Arg . Any < WebAuthnCredential > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithWebAuthnLoginCredential ( Arg . Any < WebAuthnCredential > ( ) )
. Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . BuildAsync ( ) . Returns ( Task . FromResult ( new UserDecryptionOptions
{
HasMasterPassword = true ,
@ -808,13 +887,18 @@ public class BaseRequestValidatorTests
@@ -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
@@ -833,13 +917,18 @@ public class BaseRequestValidatorTests
// Verify that the account keys query wasn't called.
await _ userAccountKeysQuery . Received ( 0 ) . Run ( Arg . Any < User > ( ) ) ;
}
[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 < User > ( ) ) . Returns ( new UserAccountKeysData
@ -853,7 +942,8 @@ public class BaseRequestValidatorTests
@@ -853,7 +942,8 @@ public class BaseRequestValidatorTests
_ userDecryptionOptionsBuilder . ForUser ( Arg . Any < User > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithDevice ( Arg . Any < Device > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithSso ( Arg . Any < SsoConfig > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithWebAuthnLoginCredential ( Arg . Any < WebAuthnCredential > ( ) ) . Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . WithWebAuthnLoginCredential ( Arg . Any < WebAuthnCredential > ( ) )
. Returns ( _ userDecryptionOptionsBuilder ) ;
_ userDecryptionOptionsBuilder . BuildAsync ( ) . Returns ( Task . FromResult ( new UserDecryptionOptions ( ) ) ) ;
var context = CreateContext ( tokenRequest , requestContext , grantResult ) ;
@ -874,6 +964,285 @@ public class BaseRequestValidatorTests
@@ -874,6 +964,285 @@ public class BaseRequestValidatorTests
await _ userAccountKeysQuery . Received ( 1 ) . Run ( Arg . Is < User > ( u = > u . Id = = expectedUser . Id ) ) ;
}
/// <summary>
/// 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)
/// </summary>
[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 < Guid > ( ) , PolicyType . RequireSso , OrganizationUserStatusType . Confirmed )
. Returns ( Task . FromResult ( true ) ) ;
// 3. 2FA is required
_ twoFactorAuthenticationValidator
. RequiresTwoFactorAsync ( user , tokenRequest )
. Returns ( Task . FromResult ( new Tuple < bool , Organization > ( 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 ) ;
}
/// <summary>
/// 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
/// </summary>
[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 < Guid > ( ) , PolicyType . RequireSso , OrganizationUserStatusType . Confirmed )
. Returns ( Task . FromResult ( true ) ) ;
// 3. 2FA is required
_ twoFactorAuthenticationValidator
. RequiresTwoFactorAsync ( user , tokenRequest )
. Returns ( Task . FromResult ( new Tuple < bool , Organization > ( 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)
_f eatureService . 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 < DateTime > ( ) ,
Arg . Any < string > ( ) ) ;
// 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 < string > ( ) ,
Arg . Any < TwoFactorProviderType > ( ) ,
Arg . Any < DateTime > ( ) ,
Arg . Any < string > ( ) ) ;
}
// 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 < User > ( u = >
u . Id = = user . Id & & u . FailedLoginCount > 0 ) ) ;
}
}
/// <summary>
/// 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.
/// </summary>
[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 < Guid > ( ) , PolicyType . RequireSso , OrganizationUserStatusType . Confirmed )
. Returns ( Task . FromResult ( false ) ) ;
// 3. 2FA is required
_ twoFactorAuthenticationValidator
. RequiresTwoFactorAsync ( user , tokenRequest )
. Returns ( Task . FromResult ( new Tuple < bool , Organization > ( 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
_d eviceValidator . ValidateRequestDeviceAsync ( tokenRequest , requestContext )
. Returns ( Task . FromResult ( true ) ) ;
// 7. User is not legacy
_ userService . IsLegacyUser ( Arg . Any < string > ( ) )
. Returns ( false ) ;
// 8. Setup user account keys for successful login response
_ 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 , "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 < User > ( 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 ,