diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index c6c96cffb9..107fd29236 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -92,6 +92,10 @@ public class GlobalSettings : IGlobalSettings public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5; public virtual bool EnableEmailVerification { get; set; } public virtual string KdfDefaultHashKey { get; set; } + /// + /// This Hash Key is used to prevent enumeration attacks against the Send Access feature. + /// + public virtual string SendDefaultHashKey { get; set; } public virtual string PricingUri { get; set; } public string BuildExternalUri(string explicitValue, string name) diff --git a/src/Core/Utilities/EnumerationProtectionHelpers.cs b/src/Core/Utilities/EnumerationProtectionHelpers.cs new file mode 100644 index 0000000000..b27c36e03a --- /dev/null +++ b/src/Core/Utilities/EnumerationProtectionHelpers.cs @@ -0,0 +1,36 @@ +using System.Text; + +namespace Bit.Core.Utilities; + +public static class EnumerationProtectionHelpers +{ + /// + /// Use this method to get a consistent int result based on the inputString that is in the range. + /// The same inputString will always return the same index result based on range input. + /// + /// Key used to derive the HMAC hash. Use a different key for each usage for optimal security + /// The string to derive an index result + /// The range of possible index values + /// An int between 0 and range - 1 + public static int GetIndexForInputHash(byte[] hmacKey, string inputString, int range) + { + if (hmacKey == null || range <= 0 || hmacKey.Length == 0) + { + return 0; + } + else + { + // Compute the HMAC hash of the salt + var hmacMessage = Encoding.UTF8.GetBytes(inputString.Trim().ToLowerInvariant()); + using var hmac = new System.Security.Cryptography.HMACSHA256(hmacKey); + var hmacHash = hmac.ComputeHash(hmacMessage); + // Convert the hash to a number + var hashHex = BitConverter.ToString(hmacHash).Replace("-", string.Empty).ToLowerInvariant(); + var hashFirst8Bytes = hashHex[..16]; + var hashNumber = long.Parse(hashFirst8Bytes, System.Globalization.NumberStyles.HexNumber); + // Find the default KDF value for this hash number + var hashIndex = (int)(Math.Abs(hashNumber) % range); + return hashIndex; + } + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs index 17ec387411..1f5bfba244 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs @@ -34,18 +34,18 @@ public static class SendAccessConstants public const string Otp = "otp"; } - public static class GrantValidatorResults + public static class SendIdGuidValidatorResults { /// - /// The sendId is valid and the request is well formed. Not returned in any response. + /// The in the request is a valid GUID and the request is well formed. Not returned in any response. /// public const string ValidSendGuid = "valid_send_guid"; /// - /// The sendId is missing from the request. + /// The is missing from the request. /// public const string SendIdRequired = "send_id_required"; /// - /// The sendId is invalid, does not match a known send. + /// The is invalid, does not match a known send. /// public const string InvalidSendId = "send_id_invalid"; } @@ -53,11 +53,11 @@ public static class SendAccessConstants public static class PasswordValidatorResults { /// - /// The passwordHashB64 does not match the send's password hash. + /// The does not match the send's password hash. /// public const string RequestPasswordDoesNotMatch = "password_hash_b64_invalid"; /// - /// The passwordHashB64 is missing from the request. + /// The is missing from the request. /// public const string RequestPasswordIsRequired = "password_hash_b64_required"; } @@ -105,4 +105,14 @@ public static class SendAccessConstants { public const string Subject = "Your Bitwarden Send verification code is {0}"; } + + /// + /// We use these static strings to help guide the enumeration protection logic. + /// + public static class EnumerationProtection + { + public const string Guid = "guid"; + public const string Password = "password"; + public const string Email = "email"; + } } diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs index d9ae946d16..101c6952f3 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs @@ -13,21 +13,19 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; public class SendAccessGrantValidator( ISendAuthenticationQuery _sendAuthenticationQuery, + ISendAuthenticationMethodValidator _sendNeverAuthenticateValidator, ISendAuthenticationMethodValidator _sendPasswordRequestValidator, ISendAuthenticationMethodValidator _sendEmailOtpRequestValidator, - IFeatureService _featureService) -: IExtensionGrantValidator + IFeatureService _featureService) : IExtensionGrantValidator { string IExtensionGrantValidator.GrantType => CustomGrantTypes.SendAccess; - private static readonly Dictionary - _sendGrantValidatorErrorDescriptions = new() + private static readonly Dictionary _sendGrantValidatorErrorDescriptions = new() { - { SendAccessConstants.GrantValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." }, - { SendAccessConstants.GrantValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." } + { SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." }, + { SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." } }; - public async Task ValidateAsync(ExtensionGrantValidationContext context) { // Check the feature flag @@ -38,7 +36,7 @@ public class SendAccessGrantValidator( } var (sendIdGuid, result) = GetRequestSendId(context); - if (result != SendAccessConstants.GrantValidatorResults.ValidSendGuid) + if (result != SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid) { context.Result = BuildErrorResult(result); return; @@ -49,15 +47,10 @@ public class SendAccessGrantValidator( switch (method) { - case NeverAuthenticate: + case NeverAuthenticate never: // null send scenario. - // TODO PM-22675: Add send enumeration protection here (primarily benefits self hosted instances). - // We should only map to password or email + OTP protected. - // If user submits password guess for a falsely protected send, then we will return invalid password. - // If user submits email + OTP guess for a falsely protected send, then we will return email sent, do not actually send an email. - context.Result = BuildErrorResult(SendAccessConstants.GrantValidatorResults.InvalidSendId); + context.Result = await _sendNeverAuthenticateValidator.ValidateRequestAsync(context, never, sendIdGuid); return; - case NotAuthenticated: // automatically issue access token context.Result = BuildBaseSuccessResult(sendIdGuid); @@ -90,7 +83,7 @@ public class SendAccessGrantValidator( // if the sendId is null then the request is the wrong shape and the request is invalid if (sendId == null) { - return (Guid.Empty, SendAccessConstants.GrantValidatorResults.SendIdRequired); + return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired); } // the send_id is not null so the request is the correct shape, so we will attempt to parse it try @@ -100,20 +93,20 @@ public class SendAccessGrantValidator( // Guid.Empty indicates an invalid send_id return invalid grant if (sendGuid == Guid.Empty) { - return (Guid.Empty, SendAccessConstants.GrantValidatorResults.InvalidSendId); + return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); } - return (sendGuid, SendAccessConstants.GrantValidatorResults.ValidSendGuid); + return (sendGuid, SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid); } catch { - return (Guid.Empty, SendAccessConstants.GrantValidatorResults.InvalidSendId); + return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); } } /// /// Builds an error result for the specified error type. /// - /// This error is a constant string from + /// This error is a constant string from /// The error result. private static GrantValidationResult BuildErrorResult(string error) { @@ -125,12 +118,12 @@ public class SendAccessGrantValidator( return error switch { // Request is the wrong shape - SendAccessConstants.GrantValidatorResults.SendIdRequired => new GrantValidationResult( + SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired => new GrantValidationResult( TokenRequestErrors.InvalidRequest, errorDescription: _sendGrantValidatorErrorDescriptions[error], customResponse), // Request is correct shape but data is bad - SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult( + SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId => new GrantValidationResult( TokenRequestErrors.InvalidGrant, errorDescription: _sendGrantValidatorErrorDescriptions[error], customResponse), diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs new file mode 100644 index 0000000000..36e033360f --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs @@ -0,0 +1,87 @@ +using System.Text; +using Bit.Core.Settings; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Utilities; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; + +/// +/// This class is used to protect our system from enumeration attacks. This Validator will always return an error result. +/// We hash the SendId Guid passed into the request to select the an error from the list of possible errors. This ensures +/// that the same error is always returned for the same SendId. +/// +/// We need access to a hash key to generate the error index. +public class SendNeverAuthenticateRequestValidator(GlobalSettings globalSettings) : ISendAuthenticationMethodValidator +{ + private readonly string[] _errorOptions = + [ + SendAccessConstants.EnumerationProtection.Guid, + SendAccessConstants.EnumerationProtection.Password, + SendAccessConstants.EnumerationProtection.Email + ]; + + public Task ValidateRequestAsync( + ExtensionGrantValidationContext context, + NeverAuthenticate authMethod, + Guid sendId) + { + var neverAuthenticateError = GetErrorIndex(sendId, _errorOptions.Length); + var request = context.Request.Raw; + var errorType = neverAuthenticateError; + + switch (neverAuthenticateError) + { + case SendAccessConstants.EnumerationProtection.Guid: + errorType = SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId; + break; + case SendAccessConstants.EnumerationProtection.Email: + var hasEmail = request.Get(SendAccessConstants.TokenRequest.Email) is not null; + errorType = hasEmail ? SendAccessConstants.EmailOtpValidatorResults.EmailInvalid + : SendAccessConstants.EmailOtpValidatorResults.EmailRequired; + break; + case SendAccessConstants.EnumerationProtection.Password: + var hasPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword) is not null; + errorType = hasPassword ? SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch + : SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired; + break; + } + + return Task.FromResult(BuildErrorResult(errorType)); + } + + private static GrantValidationResult BuildErrorResult(string errorType) + { + // Create error response with custom response data + var customResponse = new Dictionary + { + { SendAccessConstants.SendAccessError, errorType } + }; + + var requestError = errorType switch + { + SendAccessConstants.EnumerationProtection.Guid => TokenRequestErrors.InvalidGrant, + SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired => TokenRequestErrors.InvalidGrant, + SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch => TokenRequestErrors.InvalidRequest, + SendAccessConstants.EmailOtpValidatorResults.EmailInvalid => TokenRequestErrors.InvalidGrant, + SendAccessConstants.EmailOtpValidatorResults.EmailRequired => TokenRequestErrors.InvalidRequest, + _ => TokenRequestErrors.InvalidGrant + }; + + return new GrantValidationResult(requestError, errorType, customResponse); + } + + private string GetErrorIndex(Guid sendId, int range) + { + var salt = sendId.ToString(); + byte[] hmacKey = []; + if (CoreHelpers.SettingHasValue(globalSettings.SendDefaultHashKey)) + { + hmacKey = Encoding.UTF8.GetBytes(globalSettings.SendDefaultHashKey); + } + + var index = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + return _errorOptions[index]; + } +} diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index 9d062e5c06..e9056d030e 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -29,6 +29,7 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient, SendPasswordRequestValidator>(); services.AddTransient, SendEmailOtpRequestValidator>(); + services.AddTransient, SendNeverAuthenticateRequestValidator>(); var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity); var identityServerBuilder = services diff --git a/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs b/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs new file mode 100644 index 0000000000..68ac8af5d0 --- /dev/null +++ b/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs @@ -0,0 +1,230 @@ +using System.Security.Cryptography; +using System.Text; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Core.Test.Utilities; + +public class EnumerationProtectionHelpersTests +{ + #region GetIndexForInputHash Tests + + [Fact] + public void GetIndexForInputHash_NullHmacKey_ReturnsZero() + { + // Arrange + byte[] hmacKey = null; + var salt = "test@example.com"; + var range = 10; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetIndexForInputHash_ZeroRange_ReturnsZero() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "test@example.com"; + var range = 0; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetIndexForInputHash_NegativeRange_ReturnsZero() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "test@example.com"; + var range = -5; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetIndexForInputHash_ValidInputs_ReturnsConsistentResult() + { + // Arrange + var hmacKey = Encoding.UTF8.GetBytes("test-key-12345678901234567890123456789012"); + var salt = "test@example.com"; + var range = 10; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(result1, result2); + Assert.InRange(result1, 0, range - 1); + } + + [Fact] + public void GetIndexForInputHash_SameInputSameKey_AlwaysReturnsSameResult() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "consistent@example.com"; + var range = 100; + + // Act - Call multiple times + var results = new int[10]; + for (var i = 0; i < 10; i++) + { + results[i] = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + } + + // Assert - All results should be identical + Assert.All(results, result => Assert.Equal(results[0], result)); + Assert.All(results, result => Assert.InRange(result, 0, range - 1)); + } + + [Fact] + public void GetIndexForInputHash_DifferentInputsSameKey_ReturnsDifferentResults() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt1 = "user1@example.com"; + var salt2 = "user2@example.com"; + var range = 100; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt1, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt2, range); + + // Assert + Assert.NotEqual(result1, result2); + Assert.InRange(result1, 0, range - 1); + Assert.InRange(result2, 0, range - 1); + } + + [Fact] + public void GetIndexForInputHash_DifferentKeysSameInput_ReturnsDifferentResults() + { + // Arrange + var hmacKey1 = RandomNumberGenerator.GetBytes(32); + var hmacKey2 = RandomNumberGenerator.GetBytes(32); + var salt = "test@example.com"; + var range = 100; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey1, salt, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey2, salt, range); + + // Assert + Assert.NotEqual(result1, result2); + Assert.InRange(result1, 0, range - 1); + Assert.InRange(result2, 0, range - 1); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(5)] + [InlineData(10)] + [InlineData(100)] + [InlineData(1000)] + public void GetIndexForInputHash_VariousRanges_ReturnsValidIndex(int range) + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "test@example.com"; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.InRange(result, 0, range - 1); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void GetIndexForInputHash_EmptyString_HandlesGracefully(string salt) + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, 10); + + // Assert + Assert.InRange(result, 0, 9); + } + + [Fact] + public void GetIndexForInputHash_NullInput_ThrowsException() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + string salt = null; + var range = 10; + + // Act & Assert + Assert.Throws(() => + EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range)); + } + + [Fact] + public void GetIndexForInputHash_SpecialCharacters_HandlesCorrectly() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "test+user@example.com!@#$%^&*()"; + var range = 50; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(result1, result2); + Assert.InRange(result1, 0, range - 1); + } + + [Fact] + public void GetIndexForInputHash_UnicodeCharacters_HandlesCorrectly() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "tëst@éxämplé.cöm"; + var range = 25; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(result1, result2); + Assert.InRange(result1, 0, range - 1); + } + + [Fact] + public void GetIndexForInputHash_LongInput_HandlesCorrectly() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = new string('a', 1000) + "@example.com"; + var range = 30; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.InRange(result, 0, range - 1); + } + + #endregion +} diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs similarity index 81% rename from test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs rename to test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs index ca6417d49c..a6dd4b6b38 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs @@ -1,10 +1,8 @@ using Bit.Core; -using Bit.Core.Auth.IdentityServer; using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; -using Bit.Core.Utilities; using Bit.Identity.IdentityServer.Enums; using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.IntegrationTestCommon.Factories; @@ -13,16 +11,14 @@ using Duende.IdentityServer.Validation; using NSubstitute; using Xunit; -namespace Bit.Identity.IntegrationTest.RequestValidation; +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; // in order to test the default case for the authentication method, we need to create a custom one so we can ensure the // method throws as expected. internal record AnUnknownAuthenticationMethod : SendAuthenticationMethod { } -public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory factory) : IClassFixture +public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture { - private readonly IdentityApplicationFactory _factory = factory; - [Fact] public async Task SendAccessGrant_FeatureFlagDisabled_ReturnsUnsupportedGrantType() { @@ -39,7 +35,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -70,7 +66,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -125,7 +121,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -154,7 +150,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -183,7 +179,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var error = await client.PostAsync("/connect/token", requestBody); @@ -225,7 +221,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId, "password123"); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId, "password123"); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -236,37 +232,4 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory Assert.Contains("access_token", content); Assert.Contains("Bearer", content); } - - private static FormUrlEncodedContent CreateTokenRequestBody( - Guid sendId, - string password = null, - string sendEmail = null, - string emailOtp = null) - { - var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); - var parameters = new List> - { - new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess), - new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send ), - new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess), - new("deviceType", ((int)DeviceType.FirefoxBrowser).ToString()), - new(SendAccessConstants.TokenRequest.SendId, sendIdBase64) - }; - - if (!string.IsNullOrEmpty(password)) - { - parameters.Add(new(SendAccessConstants.TokenRequest.ClientB64HashedPassword, password)); - } - - if (!string.IsNullOrEmpty(emailOtp) && !string.IsNullOrEmpty(sendEmail)) - { - parameters.AddRange( - [ - new KeyValuePair("email", sendEmail), - new KeyValuePair("email_otp", emailOtp) - ]); - } - - return new FormUrlEncodedContent(parameters); - } } diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs new file mode 100644 index 0000000000..7842bf6367 --- /dev/null +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs @@ -0,0 +1,45 @@ +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.Enums; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Duende.IdentityModel; + +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; + +public static class SendAccessTestUtilities +{ + public static FormUrlEncodedContent CreateTokenRequestBody( + Guid sendId, + string email = null, + string emailOtp = null, + string password = null) + { + var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); + var parameters = new List> + { + new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess), + new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send), + new(SendAccessConstants.TokenRequest.SendId, sendIdBase64), + new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess), + new("device_type", "10") + }; + + if (!string.IsNullOrEmpty(email)) + { + parameters.Add(new KeyValuePair(SendAccessConstants.TokenRequest.Email, email)); + } + + if (!string.IsNullOrEmpty(emailOtp)) + { + parameters.Add(new KeyValuePair(SendAccessConstants.TokenRequest.Otp, emailOtp)); + } + + if (!string.IsNullOrEmpty(password)) + { + parameters.Add(new KeyValuePair(SendAccessConstants.TokenRequest.ClientB64HashedPassword, password)); + } + + return new FormUrlEncodedContent(parameters); + } +} diff --git a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs similarity index 79% rename from test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs rename to test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs index 9a097cc061..3c4657653b 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs @@ -1,28 +1,16 @@ using Bit.Core.Auth.Identity.TokenProviders; -using Bit.Core.Auth.IdentityServer; -using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; -using Bit.Core.Utilities; -using Bit.Identity.IdentityServer.Enums; -using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.IntegrationTestCommon.Factories; using Duende.IdentityModel; using NSubstitute; using Xunit; -namespace Bit.Identity.IntegrationTest.RequestValidation; +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; -public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture +public class SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture { - private readonly IdentityApplicationFactory _factory; - - public SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFactory factory) - { - _factory = factory; - } - [Fact] public async Task SendAccess_EmailOtpProtectedSend_MissingEmail_ReturnsInvalidRequest() { @@ -43,7 +31,7 @@ public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture> - { - new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess), - new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send ), - new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess), - new("deviceType", ((int)DeviceType.FirefoxBrowser).ToString()), - new(SendAccessConstants.TokenRequest.SendId, sendIdBase64) - }; - - if (!string.IsNullOrEmpty(sendEmail)) - { - parameters.Add(new KeyValuePair( - SendAccessConstants.TokenRequest.Email, sendEmail)); - } - - if (!string.IsNullOrEmpty(emailOtp)) - { - parameters.Add(new KeyValuePair( - SendAccessConstants.TokenRequest.Otp, emailOtp)); - } - - return new FormUrlEncodedContent(parameters); - } } diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs new file mode 100644 index 0000000000..a81b01a293 --- /dev/null +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs @@ -0,0 +1,168 @@ +using Bit.Core.Services; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.SendFeatures.Queries.Interfaces; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Bit.IntegrationTestCommon.Factories; +using Duende.IdentityModel; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; + +public class SendNeverAuthenticateRequestValidatorIntegrationTests( + IdentityApplicationFactory _factory) : IClassFixture +{ + /// + /// To support the static hashing function theses GUIDs and Key must be hardcoded + /// + private static readonly string _testHashKey = "test-key-123456789012345678901234567890"; + // These Guids are static and ensure the correct index for each error type + private static readonly Guid _invalidSendGuid = Guid.Parse("1b35fbf3-a14a-4d48-82b7-2adc34fdae6f"); + private static readonly Guid _emailSendGuid = Guid.Parse("bc8e2ef5-a0bd-44d2-bdb7-5902be6f5c41"); + private static readonly Guid _passwordSendGuid = Guid.Parse("da36fa27-f0e8-4701-a585-d3d8c2f67c4b"); + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_NoParameters_ReturnsInvalidSendId() + { + // Arrange + var client = ConfigureTestHttpClient(_invalidSendGuid); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_invalidSendGuid); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content); + + var expectedError = SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_ReturnsEmailRequired() + { + // Arrange + var client = ConfigureTestHttpClient(_emailSendGuid); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + // should be invalid grant + Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content); + + // Try to compel the invalid email error + var expectedError = SendAccessConstants.EmailOtpValidatorResults.EmailRequired; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_WithEmail_ReturnsEmailInvalid() + { + // Arrange + var email = "test@example.com"; + var client = ConfigureTestHttpClient(_emailSendGuid); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid, email: email); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + // should be invalid grant + Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content); + + // Try to compel the invalid email error + var expectedError = SendAccessConstants.EmailOtpValidatorResults.EmailInvalid; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_ReturnsPasswordRequired() + { + // Arrange + var client = ConfigureTestHttpClient(_passwordSendGuid); + + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_passwordSendGuid); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content); + + var expectedError = SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_WithPassword_ReturnsPasswordInvalid() + { + // Arrange + var password = "test-password-hash"; + + var client = ConfigureTestHttpClient(_passwordSendGuid); + + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_passwordSendGuid, password: password); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content); + + var expectedError = SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_ConsistentResponse_SameSendId() + { + // Arrange + var client = ConfigureTestHttpClient(_emailSendGuid); + + var requestBody1 = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid); + var requestBody2 = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid); + + // Act + var response1 = await client.PostAsync("/connect/token", requestBody1); + var response2 = await client.PostAsync("/connect/token", requestBody2); + + // Assert + var content1 = await response1.Content.ReadAsStringAsync(); + var content2 = await response2.Content.ReadAsStringAsync(); + + Assert.Equal(content1, content2); + } + + private HttpClient ConfigureTestHttpClient(Guid sendId) + { + _factory.UpdateConfiguration( + "globalSettings:sendDefaultHashKey", _testHashKey); + return _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(Arg.Any()).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + sendAuthQuery.GetAuthenticationMethod(sendId) + .Returns(new NeverAuthenticate()); + services.AddSingleton(sendAuthQuery); + }); + }).CreateClient(); + } +} diff --git a/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendPasswordRequestValidatorIntegrationTests.cs similarity index 80% rename from test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs rename to test/Identity.IntegrationTest/RequestValidation/SendAccess/SendPasswordRequestValidatorIntegrationTests.cs index 856ffe1f6e..5b03a298ed 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendPasswordRequestValidatorIntegrationTests.cs @@ -1,28 +1,17 @@ -using Bit.Core.Auth.IdentityServer; -using Bit.Core.Enums; -using Bit.Core.KeyManagement.Sends; +using Bit.Core.KeyManagement.Sends; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; -using Bit.Core.Utilities; -using Bit.Identity.IdentityServer.Enums; using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.IntegrationTestCommon.Factories; using Duende.IdentityModel; using NSubstitute; using Xunit; -namespace Bit.Identity.IntegrationTest.RequestValidation; +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; -public class SendPasswordRequestValidatorIntegrationTests : IClassFixture +public class SendPasswordRequestValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture { - private readonly IdentityApplicationFactory _factory; - - public SendPasswordRequestValidatorIntegrationTests(IdentityApplicationFactory factory) - { - _factory = factory; - } - [Fact] public async Task SendAccess_PasswordProtectedSend_ValidPassword_ReturnsAccessToken() { @@ -54,7 +43,7 @@ public class SendPasswordRequestValidatorIntegrationTests : IClassFixture> - { - new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess), - new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send), - new(SendAccessConstants.TokenRequest.SendId, sendIdBase64), - new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess), - new("deviceType", "10") - }; - - if (passwordHash != null) - { - parameters.Add(new KeyValuePair(SendAccessConstants.TokenRequest.ClientB64HashedPassword, passwordHash)); - } - - return new FormUrlEncodedContent(parameters); - } } diff --git a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs b/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs similarity index 99% rename from test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs rename to test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs index 4d243906af..91123b3a60 100644 --- a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs @@ -12,7 +12,7 @@ using Bit.Test.Common.Helpers; using Microsoft.AspNetCore.Identity; using Xunit; -namespace Bit.Identity.IntegrationTest.RequestValidation; +namespace Bit.Identity.IntegrationTest.RequestValidation.VaultAccess; public class ResourceOwnerPasswordValidatorTests : IClassFixture { diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs index 017ad70354..59d8dee2e2 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs @@ -1,12 +1,8 @@ -using System.Collections.Specialized; -using Bit.Core; +using Bit.Core; using Bit.Core.Auth.Identity; -using Bit.Core.Auth.IdentityServer; -using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; -using Bit.Core.Utilities; using Bit.Identity.IdentityServer.Enums; using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.Test.Common.AutoFixture; @@ -81,7 +77,7 @@ public class SendAccessGrantValidatorTests var context = new ExtensionGrantValidationContext(); tokenRequest.GrantType = CustomGrantTypes.SendAccess; - tokenRequest.Raw = CreateTokenRequestBody(Guid.Empty); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(Guid.Empty); // To preserve the CreateTokenRequestBody method for more general usage we over write the sendId tokenRequest.Raw.Set(SendAccessConstants.TokenRequest.SendId, "invalid-guid-format"); @@ -118,7 +114,9 @@ public class SendAccessGrantValidatorTests public async Task ValidateAsync_NeverAuthenticateMethod_ReturnsInvalidGrant( [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, SutProvider sutProvider, - Guid sendId) + NeverAuthenticate neverAuthenticate, + Guid sendId, + GrantValidationResult expectedResult) { // Arrange var context = SetupTokenRequest( @@ -128,14 +126,20 @@ public class SendAccessGrantValidatorTests sutProvider.GetDependency() .GetAuthenticationMethod(sendId) - .Returns(new NeverAuthenticate()); + .Returns(neverAuthenticate); + + sutProvider.GetDependency>() + .ValidateRequestAsync(context, neverAuthenticate, sendId) + .Returns(expectedResult); // Act await sutProvider.Sut.ValidateAsync(context); // Assert - Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.Result.Error); - Assert.Equal($"{SendAccessConstants.TokenRequest.SendId} is invalid.", context.Result.ErrorDescription); + Assert.Equal(expectedResult, context.Result); + await sutProvider.GetDependency>() + .Received(1) + .ValidateRequestAsync(context, neverAuthenticate, sendId); } [Theory, BitAutoData] @@ -264,7 +268,7 @@ public class SendAccessGrantValidatorTests public void GrantType_ReturnsCorrectType() { // Arrange & Act - var validator = new SendAccessGrantValidator(null!, null!, null!, null!); + var validator = new SendAccessGrantValidator(null!, null!, null!, null!, null!); // Assert Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType); @@ -289,44 +293,9 @@ public class SendAccessGrantValidatorTests var context = new ExtensionGrantValidationContext(); request.GrantType = CustomGrantTypes.SendAccess; - request.Raw = CreateTokenRequestBody(sendId); + request.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); context.Request = request; return context; } - - private static NameValueCollection CreateTokenRequestBody( - Guid sendId, - string passwordHash = null, - string sendEmail = null, - string otpCode = null) - { - var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); - - var rawRequestParameters = new NameValueCollection - { - { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, - { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, - { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, - { "deviceType", ((int)DeviceType.FirefoxBrowser).ToString() }, - { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } - }; - - if (passwordHash != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, passwordHash); - } - - if (sendEmail != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail); - } - - if (otpCode != null && sendEmail != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode); - } - - return rawRequestParameters; - } } diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendAccessTestUtilities.cs b/test/Identity.Test/IdentityServer/SendAccess/SendAccessTestUtilities.cs new file mode 100644 index 0000000000..b05380902a --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccess/SendAccessTestUtilities.cs @@ -0,0 +1,50 @@ +using System.Collections.Specialized; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.Enums; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Duende.IdentityModel; + +namespace Bit.Identity.Test.IdentityServer.SendAccess; + +public static class SendAccessTestUtilities +{ + public static NameValueCollection CreateValidatedTokenRequest( + Guid sendId, + string sendEmail = null, + string otpCode = null, + params string[] passwordHash) + { + var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); + + var rawRequestParameters = new NameValueCollection + { + { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, + { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, + { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, + { "device_type", ((int)DeviceType.FirefoxBrowser).ToString() }, + { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } + }; + + if (sendEmail != null) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail); + } + + if (otpCode != null && sendEmail != null) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode); + } + + if (passwordHash != null && passwordHash.Length > 0) + { + foreach (var hash in passwordHash) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, hash); + } + } + + return rawRequestParameters; + } +} diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs index 95a0a6675b..96a097a53c 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs @@ -31,9 +31,9 @@ public class SendConstantsSnapshotTests public void GrantValidatorResults_Constants_HaveCorrectValues() { // Assert - Assert.Equal("valid_send_guid", SendAccessConstants.GrantValidatorResults.ValidSendGuid); - Assert.Equal("send_id_required", SendAccessConstants.GrantValidatorResults.SendIdRequired); - Assert.Equal("send_id_invalid", SendAccessConstants.GrantValidatorResults.InvalidSendId); + Assert.Equal("valid_send_guid", SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid); + Assert.Equal("send_id_required", SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired); + Assert.Equal("send_id_invalid", SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); } [Fact] diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs index 70a1585d8b..46f61cb333 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs @@ -1,12 +1,7 @@ -using System.Collections.Specialized; -using Bit.Core.Auth.Identity; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; -using Bit.Core.Auth.IdentityServer; -using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; -using Bit.Core.Utilities; -using Bit.Identity.IdentityServer.Enums; using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -28,7 +23,7 @@ public class SendEmailOtpRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -61,8 +56,7 @@ public class SendEmailOtpRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); - var emailOTP = new EmailOtp(["user@test.dev"]); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -96,7 +90,7 @@ public class SendEmailOtpRequestValidatorTests string generatedToken) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -144,7 +138,7 @@ public class SendEmailOtpRequestValidatorTests string email) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -179,7 +173,7 @@ public class SendEmailOtpRequestValidatorTests string otp) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, otp); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email, otp); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -231,7 +225,7 @@ public class SendEmailOtpRequestValidatorTests string invalidOtp) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, invalidOtp); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email, invalidOtp); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -278,33 +272,4 @@ public class SendEmailOtpRequestValidatorTests // Assert Assert.NotNull(validator); } - - private static NameValueCollection CreateValidatedTokenRequest( - Guid sendId, - string sendEmail = null, - string otpCode = null) - { - var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); - - var rawRequestParameters = new NameValueCollection - { - { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, - { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, - { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, - { "device_type", ((int)DeviceType.FirefoxBrowser).ToString() }, - { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } - }; - - if (sendEmail != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail); - } - - if (otpCode != null && sendEmail != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode); - } - - return rawRequestParameters; - } } diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendNeverAuthenticateValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendNeverAuthenticateValidatorTests.cs new file mode 100644 index 0000000000..ae0434af83 --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccess/SendNeverAuthenticateValidatorTests.cs @@ -0,0 +1,280 @@ +using Bit.Core.Tools.Models.Data; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityModel; +using Duende.IdentityServer.Validation; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer.SendAccess; + +[SutProviderCustomize] +public class SendNeverAuthenticateRequestValidatorTests +{ + /// + /// To support the static hashing function theses GUIDs and Key must be hardcoded + /// + private static readonly string _testHashKey = "test-key-123456789012345678901234567890"; + // These Guids are static and ensure the correct index for each error type + private static readonly Guid _invalidSendGuid = Guid.Parse("1b35fbf3-a14a-4d48-82b7-2adc34fdae6f"); + private static readonly Guid _emailSendGuid = Guid.Parse("bc8e2ef5-a0bd-44d2-bdb7-5902be6f5c41"); + private static readonly Guid _passwordSendGuid = Guid.Parse("da36fa27-f0e8-4701-a585-d3d8c2f67c4b"); + + private static readonly NeverAuthenticate _authMethod = new(); + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_GuidErrorSelected_ReturnsInvalidSendId( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_invalidSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _invalidSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal(SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal( + SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmailErrorSelected_HasEmail_ReturnsEmailInvalid( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + string email) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_emailSendGuid, sendEmail: email); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _emailSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmailErrorSelected_NoEmail_ReturnsEmailRequired( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_emailSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _emailSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailRequired, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailRequired, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_PasswordErrorSelected_HasPassword_ReturnsPasswordDoesNotMatch( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + string password) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_passwordSendGuid, passwordHash: password); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _passwordSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_PasswordErrorSelected_NoPassword_ReturnsPasswordRequired( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_passwordSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _passwordSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_NullHashKey_UsesEmptyKey( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_invalidSendGuid); + var context = new ExtensionGrantValidationContext { Request = tokenRequest }; + sutProvider.GetDependency().SendDefaultHashKey = null; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _invalidSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Contains(result.ErrorDescription, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmptyHashKey_UsesEmptyKey( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_invalidSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = ""; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _invalidSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Contains(result.ErrorDescription, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_ConsistentBehavior_SameSendIdSameResult( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + Guid sendId) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = "consistent-test-key-123456789012345678901234567890"; + + // Act + var result1 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId); + var result2 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId); + + // Assert + Assert.Equal(result1.ErrorDescription, result2.ErrorDescription); + Assert.Equal(result1.Error, result2.Error); + + var customResponse1 = result1.CustomResponse as Dictionary; + var customResponse2 = result2.CustomResponse as Dictionary; + Assert.Equal(customResponse1[SendAccessConstants.SendAccessError], customResponse2[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_DifferentSendIds_CanReturnDifferentResults( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + Guid sendId1, + Guid sendId2) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId1); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = "different-test-key-123456789012345678901234567890"; + + // Act + var result1 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId1); + var result2 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId2); + + // Assert - Both should be errors + Assert.True(result1.IsError); + Assert.True(result2.IsError); + + // Both should have valid error types + var validErrors = new[] + { + SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, + SendAccessConstants.EmailOtpValidatorResults.EmailRequired, + SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired + }; + Assert.Contains(result1.ErrorDescription, validErrors); + Assert.Contains(result2.ErrorDescription, validErrors); + } + + [Fact] + public void Constructor_WithValidGlobalSettings_CreatesInstance() + { + // Arrange + var globalSettings = new Core.Settings.GlobalSettings + { + SendDefaultHashKey = "test-key-123456789012345678901234567890" + }; + + // Act + var validator = new SendNeverAuthenticateRequestValidator(globalSettings); + + // Assert + Assert.NotNull(validator); + } +} diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs index e77626d37b..460b033fa7 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs @@ -1,12 +1,7 @@ -using System.Collections.Specialized; -using Bit.Core.Auth.Identity; -using Bit.Core.Auth.IdentityServer; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.UserFeatures.SendAccess; -using Bit.Core.Enums; using Bit.Core.KeyManagement.Sends; using Bit.Core.Tools.Models.Data; -using Bit.Core.Utilities; -using Bit.Identity.IdentityServer.Enums; using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -28,7 +23,7 @@ public class SendPasswordRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); var context = new ExtensionGrantValidationContext { @@ -58,7 +53,7 @@ public class SendPasswordRequestValidatorTests string clientPasswordHash) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: clientPasswordHash); var context = new ExtensionGrantValidationContext { @@ -92,7 +87,7 @@ public class SendPasswordRequestValidatorTests string clientPasswordHash) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: clientPasswordHash); var context = new ExtensionGrantValidationContext { @@ -130,7 +125,7 @@ public class SendPasswordRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, string.Empty); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: string.Empty); var context = new ExtensionGrantValidationContext { @@ -163,7 +158,7 @@ public class SendPasswordRequestValidatorTests { // Arrange var whitespacePassword = " "; - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, whitespacePassword); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: whitespacePassword); var context = new ExtensionGrantValidationContext { @@ -196,7 +191,7 @@ public class SendPasswordRequestValidatorTests // Arrange var firstPassword = "first-password"; var secondPassword = "second-password"; - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, firstPassword, secondPassword); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: [firstPassword, secondPassword]); var context = new ExtensionGrantValidationContext { @@ -229,7 +224,7 @@ public class SendPasswordRequestValidatorTests string clientPasswordHash) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: clientPasswordHash); var context = new ExtensionGrantValidationContext { @@ -268,30 +263,4 @@ public class SendPasswordRequestValidatorTests // Assert Assert.NotNull(validator); } - - private static NameValueCollection CreateValidatedTokenRequest( - Guid sendId, - params string[] passwordHash) - { - var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); - - var rawRequestParameters = new NameValueCollection - { - { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, - { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, - { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, - { "device_type", ((int)DeviceType.FirefoxBrowser).ToString() }, - { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } - }; - - if (passwordHash != null && passwordHash.Length > 0) - { - foreach (var hash in passwordHash) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, hash); - } - } - - return rawRequestParameters; - } }