diff --git a/src/Core/Context/ICurrentContext.cs b/src/Core/Context/ICurrentContext.cs index d527cdd363..3de09f9441 100644 --- a/src/Core/Context/ICurrentContext.cs +++ b/src/Core/Context/ICurrentContext.cs @@ -32,7 +32,7 @@ public interface ICurrentContext Guid? OrganizationId { get; set; } IdentityClientType IdentityClientType { get; set; } string ClientId { get; set; } - Version ClientVersion { get; set; } + Version? ClientVersion { get; set; } bool ClientVersionIsPrerelease { get; set; } Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings); diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index aa7b281468..374dbfa083 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -4,6 +4,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Utilities; using Bit.Core.Utilities; using Microsoft.AspNetCore.Identity; @@ -216,6 +217,42 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac return SecurityVersion ?? 1; } + /// + /// Evaluates user state to determine if they are currently in a v2 encryption state. + /// + /// If the shape of their private key is v2 as well as has the proper security version then true, otherwise false + public bool HasV2Encryption() + { + return HasV2KeyShape() && IsSecurityVersionTwo(); + } + + private bool HasV2KeyShape() + { + if (string.IsNullOrEmpty(PrivateKey)) + { + return false; + } + + try + { + return EncryptionParsing.GetEncryptionType(PrivateKey) == EncryptionType.XChaCha20Poly1305_B64; + } + catch (ArgumentException) + { + // Invalid encryption string format - treat as not v2 + return false; + } + } + + /// + /// This technically is correct but all versions after 1 are considered v2 encryption. Leaving for now with + /// KM's blessing that when a new version comes along they will handle migration. + /// + private bool IsSecurityVersionTwo() + { + return SecurityVersion == 2; + } + /// /// Serializes the C# object to the User.TwoFactorProviders property in JSON format. /// diff --git a/src/Core/Enums/EncryptionType.cs b/src/Core/Enums/EncryptionType.cs index 52231e047c..d910e22e56 100644 --- a/src/Core/Enums/EncryptionType.cs +++ b/src/Core/Enums/EncryptionType.cs @@ -11,8 +11,11 @@ public enum EncryptionType : byte XChaCha20Poly1305_B64 = 7, // asymmetric + [Obsolete("PM-29656 - Should probably be removed as it is not known to exist in the real world")] Rsa2048_OaepSha256_B64 = 3, Rsa2048_OaepSha1_B64 = 4, + [Obsolete("PM-29656 - Should probably be removed as it is not known to exist in the real world")] Rsa2048_OaepSha256_HmacSha256_B64 = 5, + [Obsolete("PM-29656 - Should probably be removed as it is not known to exist in the real world")] Rsa2048_OaepSha1_HmacSha256_B64 = 6 } diff --git a/src/Core/KeyManagement/Constants.cs b/src/Core/KeyManagement/Constants.cs new file mode 100644 index 0000000000..f1e3e1a268 --- /dev/null +++ b/src/Core/KeyManagement/Constants.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.KeyManagement; + +public static class Constants +{ + public static readonly Version MinimumClientVersionForV2Encryption = new("2025.11.0"); +} diff --git a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs similarity index 91% rename from src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs rename to src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs index 8b5d5ea75c..8091055fce 100644 --- a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs +++ b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs @@ -6,6 +6,7 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Repositories; +using Bit.Core.KeyManagement.Utilities; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -159,7 +160,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand } else { - if (GetEncryptionType(model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey) != EncryptionType.AesCbc256_HmacSha256_B64) + if (EncryptionParsing.GetEncryptionType(model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey) != EncryptionType.AesCbc256_HmacSha256_B64) { throw new InvalidOperationException("The provided account private key was not wrapped with AES-256-CBC-HMAC"); } @@ -231,7 +232,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand { // Returns whether the user is a V2 user based on the private key's encryption type. ArgumentNullException.ThrowIfNull(user); - var isPrivateKeyEncryptionV2 = GetEncryptionType(user.PrivateKey) == EncryptionType.XChaCha20Poly1305_B64; + var isPrivateKeyEncryptionV2 = EncryptionParsing.GetEncryptionType(user.PrivateKey) == EncryptionType.XChaCha20Poly1305_B64; return isPrivateKeyEncryptionV2; } @@ -259,7 +260,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand { throw new InvalidOperationException("Signature key pair data is required for V2 encryption."); } - if (GetEncryptionType(model.AccountKeys.SignatureKeyPairData.WrappedSigningKey) != EncryptionType.XChaCha20Poly1305_B64) + if (EncryptionParsing.GetEncryptionType(model.AccountKeys.SignatureKeyPairData.WrappedSigningKey) != EncryptionType.XChaCha20Poly1305_B64) { throw new InvalidOperationException("The provided signing key data is not wrapped with XChaCha20-Poly1305."); } @@ -268,7 +269,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand throw new InvalidOperationException("The provided signature key pair data does not contain a valid verifying key."); } - if (GetEncryptionType(model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey) != EncryptionType.XChaCha20Poly1305_B64) + if (EncryptionParsing.GetEncryptionType(model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey) != EncryptionType.XChaCha20Poly1305_B64) { throw new InvalidOperationException("The provided private key encryption key is not wrapped with XChaCha20-Poly1305."); } @@ -281,24 +282,4 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand throw new InvalidOperationException("No signed security state provider for V2 user"); } } - - /// - /// Helper method to convert an encryption type string to an enum value. - /// - private static EncryptionType GetEncryptionType(string encString) - { - var parts = encString.Split('.'); - if (parts.Length == 1) - { - throw new ArgumentException("Invalid encryption type string."); - } - if (byte.TryParse(parts[0], out var encryptionTypeNumber)) - { - if (Enum.IsDefined(typeof(EncryptionType), encryptionTypeNumber)) - { - return (EncryptionType)encryptionTypeNumber; - } - } - throw new ArgumentException("Invalid encryption type string."); - } } diff --git a/src/Core/KeyManagement/Utilities/EncryptionParsing.cs b/src/Core/KeyManagement/Utilities/EncryptionParsing.cs new file mode 100644 index 0000000000..96a3117cf7 --- /dev/null +++ b/src/Core/KeyManagement/Utilities/EncryptionParsing.cs @@ -0,0 +1,28 @@ +using Bit.Core.Enums; + +namespace Bit.Core.KeyManagement.Utilities; + +public static class EncryptionParsing +{ + /// + /// Helper method to convert an encryption type string to an enum value. + /// + public static EncryptionType GetEncryptionType(string? encString) + { + ArgumentNullException.ThrowIfNull(encString); + + var parts = encString.Split('.'); + if (parts.Length == 1) + { + throw new ArgumentException("Invalid encryption type string."); + } + if (byte.TryParse(parts[0], out var encryptionTypeNumber)) + { + if (Enum.IsDefined(typeof(EncryptionType), encryptionTypeNumber)) + { + return (EncryptionType)encryptionTypeNumber; + } + } + throw new ArgumentException("Invalid encryption type string."); + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 289feebdb2..f343703665 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -40,6 +40,7 @@ public abstract class BaseRequestValidator where T : class private readonly IUserRepository _userRepository; private readonly IAuthRequestRepository _authRequestRepository; private readonly IMailService _mailService; + private readonly IClientVersionValidator _clientVersionValidator; protected ICurrentContext CurrentContext { get; } protected IPolicyService PolicyService { get; } @@ -69,7 +70,8 @@ public abstract class BaseRequestValidator where T : class IPolicyRequirementQuery policyRequirementQuery, IAuthRequestRepository authRequestRepository, IMailService mailService, - IUserAccountKeysQuery userAccountKeysQuery + IUserAccountKeysQuery userAccountKeysQuery, + IClientVersionValidator clientVersionValidator ) { _userManager = userManager; @@ -91,6 +93,7 @@ public abstract class BaseRequestValidator where T : class _authRequestRepository = authRequestRepository; _mailService = mailService; _accountKeysQuery = userAccountKeysQuery; + _clientVersionValidator = clientVersionValidator; } protected async Task ValidateAsync(T context, ValidatedTokenRequest request, @@ -135,7 +138,13 @@ public abstract class BaseRequestValidator where T : class // validation to perform the recovery as part of scheme validation based on the request. return [ - () => ValidateMasterPasswordAsync(context, validatorContext), + () => ValidateGrantSpecificContext(context, validatorContext), + // Now check the version number of the client. Do this after ValidateContextAsync so that + // we prevent account enumeration. If we were to do this before ValidateContextAsync, then attackers + // could use a known invalid client version and make a request for a user (before we know if they have + // demonstrated ownership of the account via correct credentials) and identify if they exist by getting + // an error response back from the validator saying the user is not compatible with the client. + () => ValidateClientVersionAsync(context, validatorContext), () => ValidateTwoFactorAsync(context, request, validatorContext), () => ValidateSsoAsync(context, request, validatorContext), () => ValidateNewDeviceAsync(context, request, validatorContext), @@ -148,7 +157,13 @@ public abstract class BaseRequestValidator where T : class // The typical validation scenario. return [ - () => ValidateMasterPasswordAsync(context, validatorContext), + () => ValidateGrantSpecificContext(context, validatorContext), + // Now check the version number of the client. Do this after ValidateContextAsync so that + // we prevent account enumeration. If we were to do this before ValidateContextAsync, then attackers + // could use a known invalid client version and make a request for a user (before we know if they have + // demonstrated ownership of the account via correct credentials) and identify if they exist by getting + // an error response back from the validator saying the user is not compatible with the client. + () => ValidateClientVersionAsync(context, validatorContext), () => ValidateSsoAsync(context, request, validatorContext), () => ValidateTwoFactorAsync(context, request, validatorContext), () => ValidateNewDeviceAsync(context, request, validatorContext), @@ -201,12 +216,29 @@ public abstract class BaseRequestValidator where T : class } /// - /// Validates the user's Master Password hash. + /// Validates whether the client version is compatible for the user attempting to authenticate. + /// + /// true if the scheme successfully passed validation, otherwise false. + private async Task ValidateClientVersionAsync(T context, CustomValidatorRequestContext validatorContext) + { + var ok = _clientVersionValidator.Validate(validatorContext.User, validatorContext); + if (ok) + { + return true; + } + + SetValidationErrorResult(context, validatorContext); + await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn); + return false; + } + + /// + /// Validates the user's master password, webauthen, or custom token request via the appropriate context validator. /// /// The current request context. /// /// true if the scheme successfully passed validation, otherwise false. - private async Task ValidateMasterPasswordAsync(T context, CustomValidatorRequestContext validatorContext) + private async Task ValidateGrantSpecificContext(T context, CustomValidatorRequestContext validatorContext) { var valid = await ValidateContextAsync(context, validatorContext); var user = validatorContext.User; diff --git a/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs b/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs new file mode 100644 index 0000000000..0936b4e8ff --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs @@ -0,0 +1,92 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.KeyManagement; +using Bit.Core.Models.Api; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators; + +public interface IClientVersionValidator +{ + bool Validate(User user, CustomValidatorRequestContext requestContext); +} + +/// +/// This validator will use the Client Version on a request, which currently maps +/// to the "Bitwarden-Client-Version" header, to determine if a user meets minimum +/// required client version for issuing tokens on an old client. This is done to +/// incentivize users to get on an updated client when their password encryption +/// method has already been updated. +/// +/// If the header is omitted, then the validator returns that this request is invalid. +/// +public class ClientVersionValidator( + ICurrentContext currentContext) + : IClientVersionValidator +{ + private const string _upgradeMessage = "Please update your app to continue using Bitwarden"; + private const string _noUserMessage = "No user found while trying to validate client version"; + private const string _versionHeaderMissing = "No client version header found, required to prevent encryption errors. Please confirm your client is supplying the header: \"Bitwarden-Client-Version\""; + + public bool Validate(User? user, CustomValidatorRequestContext requestContext) + { + // Do this nullish check because the base request validator currently is not + // strict null checking. Once that gets fixed then we can see about making + // the user not nullish checked. If they are null then the validator should fail. + if (user == null) + { + FillRequestContextWithErrorData(requestContext, "no_user", _noUserMessage); + return false; + } + + Version? clientVersion = currentContext.ClientVersion; + + // Deny access if the client version headers are missing. + // We want to establish a strict contract with clients that if they omit this header, + // then the server cannot guarantee that a client won't do harm to a user's data + // with stale encryption architecture. + if (clientVersion == null) + { + FillRequestContextWithErrorData(requestContext, "version_header_missing", _versionHeaderMissing); + return false; + } + + // Determine the minimum version client that a user needs. If no V2 encryption detected then + // no validation needs to occur, which is why min version number can be null. + Version? minVersion = user.HasV2Encryption() ? Constants.MinimumClientVersionForV2Encryption : null; + + // If min version is null then we know that the user had an encryption + // configuration that doesn't require a minimum version. Allowing through. + if (minVersion == null) + { + return true; + } + + if (clientVersion < minVersion) + { + FillRequestContextWithErrorData(requestContext, "invalid_client_version", _upgradeMessage); + return false; + } + + return true; + } + + private void FillRequestContextWithErrorData( + CustomValidatorRequestContext requestContext, + string errorId, + string errorMessage) + { + requestContext.ValidationErrorResult = new ValidationResult + { + Error = errorId, + ErrorDescription = errorMessage, + IsError = true + }; + requestContext.CustomResponse = new Dictionary + { + { "ErrorModel", new ErrorResponseModel(errorMessage) } + }; + } +} + + diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index 2412c52308..292cc48438 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -16,11 +16,8 @@ using Bit.Core.Settings; using Duende.IdentityModel; using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Validation; -using HandlebarsDotNet; using Microsoft.AspNetCore.Identity; -#nullable enable - namespace Bit.Identity.IdentityServer.RequestValidators; public class CustomTokenRequestValidator : BaseRequestValidator, @@ -50,7 +47,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 4139b59fa5..988e884813 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -6,7 +6,6 @@ using System.Reflection; using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using AspNetCoreRateLimit; -using Bit.Core; using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -83,6 +82,7 @@ using Microsoft.Extensions.Options; using Microsoft.OpenApi; using StackExchange.Redis; using Swashbuckle.AspNetCore.SwaggerGen; +using Constants = Bit.Core.Constants; using NoopRepos = Bit.Core.Repositories.Noop; using Role = Bit.Core.Entities.Role; using TableStorageRepos = Bit.Core.Repositories.TableStorage; diff --git a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs index 173580ad8c..7cc827bc6e 100644 --- a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs +++ b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs @@ -3,6 +3,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Enums; using Bit.IntegrationTestCommon; using Bit.IntegrationTestCommon.Factories; +using Bit.Test.Common.Constants; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.TestHost; using Xunit; @@ -66,10 +67,10 @@ public class ApiApplicationFactory : WebApplicationFactoryBase KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserAsymmetricKeys = new KeysRequestModel() { - PublicKey = "public_key", - EncryptedPrivateKey = "private_key" + PublicKey = TestEncryptionConstants.PublicKey, + EncryptedPrivateKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring }, - UserSymmetricKey = "sym_key", + UserSymmetricKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring, }); return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); diff --git a/test/Common/Constants/TestEncryptionConstants.cs b/test/Common/Constants/TestEncryptionConstants.cs new file mode 100644 index 0000000000..08022fa83d --- /dev/null +++ b/test/Common/Constants/TestEncryptionConstants.cs @@ -0,0 +1,24 @@ +namespace Bit.Test.Common.Constants; + +public static class TestEncryptionConstants +{ + + // Simple stubs for different encrypted string versions + [Obsolete] + public const string AES256_CBC_B64_Encstring = "0.stub"; + public const string AES256_CBC_HMAC_EmptySuffix = "2."; + // Intended for use as a V1 encrypted string, accepted by validators + public const string AES256_CBC_HMAC_Encstring = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA=="; + public const string RSA2048_OAEPSHA1_B64_Encstring = "4.stub"; + public const string XCHACHA20POLY1305_B64_Encstring = "7.stub"; + + // Public key test placeholder + public const string PublicKey = "pk_test"; + + // V2-style values used across tests + // Private key indicating v2 (used in multiple tests to mark v2 state) + public const string V2PrivateKey = "7.cose"; + // Wrapped signing key and verifying key values from real tests + public const string V2WrappedSigningKey = "7.cose_signing"; + public const string V2VerifyingKey = "vk"; +} diff --git a/test/Core.Test/AutoFixture/GlobalSettingsFixtures.cs b/test/Core.Test/AutoFixture/GlobalSettingsFixtures.cs index 020b097077..e3d36fdc71 100644 --- a/test/Core.Test/AutoFixture/GlobalSettingsFixtures.cs +++ b/test/Core.Test/AutoFixture/GlobalSettingsFixtures.cs @@ -3,7 +3,6 @@ using System.Text; using AutoFixture; using AutoFixture.Kernel; using AutoFixture.Xunit2; -using Bit.Core; using Bit.Core.Test.Helpers.Factories; using Microsoft.AspNetCore.DataProtection; using NSubstitute; @@ -36,11 +35,11 @@ public class GlobalSettingsBuilder : ISpecimenBuilder var dataProtector = Substitute.For(); dataProtector.Unprotect(Arg.Any()) .Returns(data => - Encoding.UTF8.GetBytes(Constants.DatabaseFieldProtectedPrefix + + Encoding.UTF8.GetBytes(Core.Constants.DatabaseFieldProtectedPrefix + Encoding.UTF8.GetString((byte[])data[0]))); var dataProtectionProvider = Substitute.For(); - dataProtectionProvider.CreateProtector(Constants.DatabaseFieldProtectorPurpose) + dataProtectionProvider.CreateProtector(Core.Constants.DatabaseFieldProtectorPurpose) .Returns(dataProtector); return dataProtectionProvider; diff --git a/test/Core.Test/KeyManagement/Utilities/EncryptionParsingTests.cs b/test/Core.Test/KeyManagement/Utilities/EncryptionParsingTests.cs new file mode 100644 index 0000000000..a4837c91d0 --- /dev/null +++ b/test/Core.Test/KeyManagement/Utilities/EncryptionParsingTests.cs @@ -0,0 +1,40 @@ +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Utilities; +using Bit.Test.Common.Constants; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.Utilities; + +public class EncryptionParsingTests +{ + [Fact] + public void GetEncryptionType_WithNull_ThrowsArgumentNullException() + { + Assert.Throws(() => EncryptionParsing.GetEncryptionType(null)); + } + + [Theory] + [InlineData("2")] // missing '.' separator + [InlineData("abc.def")] // non-numeric prefix + [InlineData("8.any")] // undefined enum value + [InlineData("255.any")] // out of defined enum range + public void GetEncryptionType_WithInvalidString_ThrowsArgumentException(string input) + { + Assert.Throws(() => EncryptionParsing.GetEncryptionType(input)); + } + + [Theory] + [InlineData(TestEncryptionConstants.AES256_CBC_B64_Encstring, EncryptionType.AesCbc256_B64)] + [InlineData(TestEncryptionConstants.AES256_CBC_HMAC_Encstring, EncryptionType.AesCbc256_HmacSha256_B64)] + [InlineData(TestEncryptionConstants.RSA2048_OAEPSHA1_B64_Encstring, EncryptionType.Rsa2048_OaepSha1_B64)] + [InlineData(TestEncryptionConstants.V2PrivateKey, EncryptionType.XChaCha20Poly1305_B64)] + [InlineData(TestEncryptionConstants.V2WrappedSigningKey, EncryptionType.XChaCha20Poly1305_B64)] + [InlineData(TestEncryptionConstants.AES256_CBC_HMAC_EmptySuffix, EncryptionType.AesCbc256_HmacSha256_B64)] // empty suffix still valid + [InlineData(TestEncryptionConstants.XCHACHA20POLY1305_B64_Encstring, EncryptionType.XChaCha20Poly1305_B64)] + public void GetEncryptionType_WithValidString_ReturnsExpected(string input, EncryptionType expected) + { + var result = EncryptionParsing.GetEncryptionType(input); + Assert.Equal(expected, result); + } +} + diff --git a/test/Events.IntegrationTest/EventsApplicationFactory.cs b/test/Events.IntegrationTest/EventsApplicationFactory.cs index 7d692c442a..3530d0aa6c 100644 --- a/test/Events.IntegrationTest/EventsApplicationFactory.cs +++ b/test/Events.IntegrationTest/EventsApplicationFactory.cs @@ -3,6 +3,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Enums; using Bit.IntegrationTestCommon; using Bit.IntegrationTestCommon.Factories; +using Bit.Test.Common.Constants; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; @@ -58,10 +59,10 @@ public class EventsApplicationFactory : WebApplicationFactoryBase KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserAsymmetricKeys = new KeysRequestModel() { - PublicKey = "public_key", - EncryptedPrivateKey = "private_key" + PublicKey = TestEncryptionConstants.PublicKey, + EncryptedPrivateKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring }, - UserSymmetricKey = "sym_key", + UserSymmetricKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring, }); return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs index 1ed2cac17a..1c7b035874 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs @@ -15,7 +15,10 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Utilities; +using Bit.Identity.IdentityServer; +using Bit.Identity.IdentityServer.RequestValidators; using Bit.IntegrationTestCommon.Factories; +using Bit.Test.Common.Constants; using Bit.Test.Common.Helpers; using Duende.IdentityModel; using Duende.IdentityServer.Models; @@ -310,8 +313,8 @@ public class IdentityServerSsoTests var user = await factory.Services.GetRequiredService().GetByEmailAsync(TestEmail); Assert.NotNull(user); - const string expectedPrivateKey = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA=="; - const string expectedUserKey = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA=="; + const string expectedPrivateKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring; + const string expectedUserKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring; var device = await deviceRepository.CreateAsync(new Device { @@ -320,7 +323,7 @@ public class IdentityServerSsoTests Name = "Thing", UserId = user.Id, EncryptedPrivateKey = expectedPrivateKey, - EncryptedPublicKey = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", + EncryptedPublicKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring, EncryptedUserKey = expectedUserKey, }); @@ -540,21 +543,70 @@ public class IdentityServerSsoTests }, challenge, trustedDeviceEnabled); await configureFactory(factory); - var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary + var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent( + new Dictionary + { + { "scope", "api offline_access" }, + { "client_id", "web" }, + { "deviceType", "10" }, + { "deviceIdentifier", "test_id" }, + { "deviceName", "firefox" }, + { "twoFactorToken", "TEST" }, + { "twoFactorProvider", "5" }, // RememberMe Provider + { "twoFactorRemember", "0" }, + { "grant_type", "authorization_code" }, + { "code", "test_code" }, + { "code_verifier", challenge }, + { "redirect_uri", "https://localhost:8080/sso-connector.html" } + })); + + // If this fails, surface detailed error information to aid debugging + if (context.Response.StatusCode != StatusCodes.Status200OK) { - { "scope", "api offline_access" }, - { "client_id", "web" }, - { "deviceType", "10" }, - { "deviceIdentifier", "test_id" }, - { "deviceName", "firefox" }, - { "twoFactorToken", "TEST"}, - { "twoFactorProvider", "5" }, // RememberMe Provider - { "twoFactorRemember", "0" }, - { "grant_type", "authorization_code" }, - { "code", "test_code" }, - { "code_verifier", challenge }, - { "redirect_uri", "https://localhost:8080/sso-connector.html" } - })); + string contentType = context.Response.ContentType ?? string.Empty; + string rawBody = ""; + try + { + if (context.Response.Body.CanSeek) + { + context.Response.Body.Position = 0; + } + using var reader = new StreamReader(context.Response.Body, leaveOpen: true); + rawBody = await reader.ReadToEndAsync(); + } + catch + { + // leave rawBody as unreadable + } + + string? error = null; + string? errorDesc = null; + string? errorModelMsg = null; + try + { + using var doc = JsonDocument.Parse(rawBody); + var root = doc.RootElement; + if (root.TryGetProperty("error", out var e)) error = e.GetString(); + if (root.TryGetProperty("error_description", out var ed)) errorDesc = ed.GetString(); + if (root.TryGetProperty("ErrorModel", out var em) && em.ValueKind == JsonValueKind.Object) + { + if (em.TryGetProperty("Message", out var msg) && msg.ValueKind == JsonValueKind.String) + { + errorModelMsg = msg.GetString(); + } + } + } + catch + { + // Not JSON, continue with raw body + } + + var message = + $"Unexpected status {context.Response.StatusCode}." + + $" error='{error}' error_description='{errorDesc}' ErrorModel.Message='{errorModelMsg}'" + + $" ContentType='{contentType}' RawBody='{rawBody}'"; + Assert.Fail(message); + } // Only calls that result in a 200 OK should call this helper Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); @@ -570,6 +622,13 @@ public class IdentityServerSsoTests { var factory = new IdentityApplicationFactory(); + // Bypass client version gating to isolate SSO test behavior + factory.SubstituteService(svc => + { + svc.Validate(Arg.Any(), Arg.Any()) + .Returns(true); + }); + var authorizationCode = new AuthorizationCode { ClientId = "web", @@ -584,6 +643,7 @@ public class IdentityServerSsoTests factory.SubstituteService(service => { + // Return our pre-built authorization code regardless of handle representation service.GetAuthorizationCodeAsync("test_code") .Returns(authorizationCode); }); @@ -597,10 +657,10 @@ public class IdentityServerSsoTests KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserAsymmetricKeys = new KeysRequestModel() { - PublicKey = "public_key", - EncryptedPrivateKey = "private_key" + PublicKey = TestEncryptionConstants.PublicKey, + EncryptedPrivateKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring // v1-format so parsing succeeds and user is treated as v1 }, - UserSymmetricKey = "sym_key", + UserSymmetricKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring, }); var organizationRepository = factory.Services.GetRequiredService(); diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs index 9f5fc2aaea..a4e6c6798e 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs @@ -9,11 +9,14 @@ using Bit.Core.Enums; using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.Test.Auth.AutoFixture; +using Bit.Identity.IdentityServer; +using Bit.Identity.IdentityServer.RequestValidators; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; +using NSubstitute; using Xunit; namespace Bit.Identity.IntegrationTest.Endpoints; @@ -36,6 +39,14 @@ public class IdentityServerTests : IClassFixture public IdentityServerTests(IdentityApplicationFactory factory) { _factory = factory; + + // Bypass client version gating to isolate SSO test behavior + _factory.SubstituteService(svc => + { + svc.Validate(Arg.Any(), Arg.Any()) + .Returns(true); + }); + ReinitializeDbForTests(_factory); } diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs index a04b8acf19..d0d35f5d7b 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs @@ -387,10 +387,10 @@ public class IdentityServerTwoFactorTests : IClassFixture(); diff --git a/test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs b/test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs new file mode 100644 index 0000000000..a05e187c89 --- /dev/null +++ b/test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs @@ -0,0 +1,149 @@ +using System.Text.Json; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.KeyManagement.Enums; +using Bit.Core.Test.Auth.AutoFixture; +using Bit.IntegrationTestCommon.Factories; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Constants; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Bit.Identity.IntegrationTest.Login; + +public class ClientVersionGateTests : IClassFixture +{ + private readonly IdentityApplicationFactory _factory; + private const string DefaultEncryptedString = "2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg="; + + public ClientVersionGateTests(IdentityApplicationFactory factory) + { + _factory = factory; + ReinitializeDbForTests(_factory); + } + + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypePassword_V2User_OnOldClientVersion_Blocked(RegisterFinishRequestModel requestModel) + { + NormalizeEncryptedStrings(requestModel); + var localFactory = new IdentityApplicationFactory + { + UseMockClientVersionValidator = false + }; + var server = localFactory.Server; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); + + // Make user V2: set private key to COSE and add signature key pair + var db = localFactory.GetDatabaseContext(); + var efUser = await db.Users.FirstAsync(u => u.Email == user.Email); + efUser.PrivateKey = TestEncryptionConstants.V2PrivateKey; + efUser.SecurityVersion = 2; + db.UserSignatureKeyPairs.Add(new Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair + { + Id = Core.Utilities.CoreHelpers.GenerateComb(), + UserId = efUser.Id, + SignatureAlgorithm = SignatureAlgorithm.Ed25519, + SigningKey = TestEncryptionConstants.V2WrappedSigningKey, + VerifyingKey = TestEncryptionConstants.V2VerifyingKey, + }); + await db.SaveChangesAsync(); + + var context = await server.PostAsync("/connect/token", + new FormUrlEncodedContent(new Dictionary + { + { "scope", "api offline_access" }, + { "client_id", "web" }, + { "deviceType", "2" }, + { "deviceIdentifier", IdentityApplicationFactory.DefaultDeviceIdentifier }, + { "deviceName", "firefox" }, + { "grant_type", "password" }, + { "username", user.Email }, + { "password", requestModel.MasterPasswordHash }, + }), + http => + { + http.Request.Headers.Append("Bitwarden-Client-Version", "2025.10.0"); + }); + + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + var errorBody = await Bit.Test.Common.Helpers.AssertHelper.AssertResponseTypeIs(context); + var error = Bit.Test.Common.Helpers.AssertHelper.AssertJsonProperty(errorBody.RootElement, "ErrorModel", JsonValueKind.Object); + var message = Bit.Test.Common.Helpers.AssertHelper.AssertJsonProperty(error, "Message", JsonValueKind.String).GetString(); + Assert.Equal("Please update your app to continue using Bitwarden", message); + } + + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypePassword_V2User_OnMinClientVersion_Succeeds(RegisterFinishRequestModel requestModel) + { + NormalizeEncryptedStrings(requestModel); + var localFactory = new IdentityApplicationFactory + { + UseMockClientVersionValidator = false + }; + var server = localFactory.Server; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); + + // Make user V2 + var db = localFactory.GetDatabaseContext(); + var efUser = await db.Users.FirstAsync(u => u.Email == user.Email); + efUser.PrivateKey = TestEncryptionConstants.V2PrivateKey; + efUser.SecurityVersion = 2; + db.UserSignatureKeyPairs.Add(new Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair + { + Id = Core.Utilities.CoreHelpers.GenerateComb(), + UserId = efUser.Id, + SignatureAlgorithm = SignatureAlgorithm.Ed25519, + SigningKey = TestEncryptionConstants.V2WrappedSigningKey, + VerifyingKey = TestEncryptionConstants.V2VerifyingKey, + }); + await db.SaveChangesAsync(); + + var context = await server.PostAsync("/connect/token", + new FormUrlEncodedContent(new Dictionary + { + { "scope", "api offline_access" }, + { "client_id", "web" }, + { "deviceType", "2" }, + { "deviceIdentifier", IdentityApplicationFactory.DefaultDeviceIdentifier }, + { "deviceName", "firefox" }, + { "grant_type", "password" }, + { "username", user.Email }, + { "password", requestModel.MasterPasswordHash }, + }), + http => + { + http.Request.Headers.Append("Bitwarden-Client-Version", "2025.11.0"); + }); + + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + + private void ReinitializeDbForTests(IdentityApplicationFactory factory) + { + var databaseContext = factory.GetDatabaseContext(); + databaseContext.Policies.RemoveRange(databaseContext.Policies); + databaseContext.OrganizationUsers.RemoveRange(databaseContext.OrganizationUsers); + databaseContext.Organizations.RemoveRange(databaseContext.Organizations); + databaseContext.Users.RemoveRange(databaseContext.Users); + databaseContext.SaveChanges(); + } + + private static void NormalizeEncryptedStrings(RegisterFinishRequestModel requestModel) + { + var accountKeys = requestModel.UserAsymmetricKeys.AccountKeys; + if (accountKeys == null) + { + return; + } + + accountKeys.UserKeyEncryptedAccountPrivateKey = DefaultEncryptedString; + if (accountKeys.PublicKeyEncryptionKeyPair != null) + { + accountKeys.PublicKeyEncryptionKeyPair.WrappedPrivateKey = DefaultEncryptedString; + } + if (accountKeys.SignatureKeyPair != null) + { + accountKeys.SignatureKeyPair.WrappedSigningKey = DefaultEncryptedString; + accountKeys.SignatureKeyPair.SignatureAlgorithm = "ed25519"; + } + } +} diff --git a/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs b/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs index 91123b3a60..54fb80eed0 100644 --- a/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs @@ -613,10 +613,10 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture(); _mailService = Substitute.For(); _userAccountKeysQuery = Substitute.For(); + _clientVersionValidator = Substitute.For(); _sut = new BaseRequestValidatorTestWrapper( _userManager, @@ -102,7 +104,13 @@ public class BaseRequestValidatorTests _policyRequirementQuery, _authRequestRepository, _mailService, - _userAccountKeysQuery); + _userAccountKeysQuery, + _clientVersionValidator); + + // Default client version validator behavior: allow to pass unless a test overrides. + _clientVersionValidator + .Validate(Arg.Any(), Arg.Any()) + .Returns(true); } /* Logic path @@ -1266,6 +1274,38 @@ public class BaseRequestValidatorTests "TwoFactorRecoveryRequested flag should be set for audit/logging"); } + [Theory] + [BitAutoData] + public async Task ValidateAsync_ClientVersionValidator_IsInvoked( + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + var context = CreateContext(tokenRequest, requestContext, grantResult); + _sut.isValid = true; // ensure initial context validation passes + + // Force a grant type that will evaluate SSO after client version validation + context.ValidatedTokenRequest.GrantType = "password"; + + // Make client version validation succeed but ensure it's invoked + _clientVersionValidator + .Validate(requestContext.User, requestContext) + .Returns(true); + + // Ensure SSO requirement triggers an early stop after version validation to avoid success path setup + _policyService.AnyPoliciesApplicableToUserAsync( + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + .Returns(Task.FromResult(true)); + + // Act + await _sut.ValidateAsync(context); + + // Assert + _clientVersionValidator.Received(1) + .Validate(requestContext.User, requestContext); + } + /// /// Tests that when SSO validation returns a custom response, (e.g., with organization identifier), /// that custom response is properly propagated to the result. diff --git a/test/Identity.Test/IdentityServer/RequestValidators/ClientVersionValidatorTests.cs b/test/Identity.Test/IdentityServer/RequestValidators/ClientVersionValidatorTests.cs new file mode 100644 index 0000000000..51b1c73a73 --- /dev/null +++ b/test/Identity.Test/IdentityServer/RequestValidators/ClientVersionValidatorTests.cs @@ -0,0 +1,127 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Identity.IdentityServer.RequestValidators; +using Bit.Test.Common.Constants; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer.RequestValidators; + +public class ClientVersionValidatorTests +{ + private static ICurrentContext MakeContext(Version? version) + { + var ctx = Substitute.For(); + ctx.ClientVersion = version; + return ctx; + } + + private static User MakeValidV2User() + { + return new User + { + PrivateKey = TestEncryptionConstants.V2PrivateKey, + SecurityVersion = 2 + }; + } + + [Fact] + public void Allows_When_ClientMeetsMinimumVersion() + { + // Arrange + var sut = new ClientVersionValidator(MakeContext(new Version("2025.11.0"))); + var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext(); + var user = MakeValidV2User(); + + // Act + var ok = sut.Validate(user, ctx); + + // Assert + Assert.True(ok); + } + + [Fact] + public void Blocks_When_ClientTooOld() + { + // Arrange + var sut = new ClientVersionValidator(MakeContext(new Version("2025.10.0"))); + var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext(); + var user = MakeValidV2User(); + + // Act + var ok = sut.Validate(user, ctx); + + // Assert + Assert.False(ok); + Assert.NotNull(ctx.ValidationErrorResult); + Assert.True(ctx.ValidationErrorResult.IsError); + Assert.Equal("invalid_client_version", ctx.ValidationErrorResult.Error); + } + + [Fact] + public void Blocks_When_NullUser() + { + // Arrange + var sut = new ClientVersionValidator(MakeContext(new Version("2025.11.0"))); + var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext(); + User? user = null; + + // Act + var ok = sut.Validate(user, ctx); + + // Assert + Assert.False(ok); + Assert.NotNull(ctx.ValidationErrorResult); + Assert.True(ctx.ValidationErrorResult.IsError); + Assert.Equal("no_user", ctx.ValidationErrorResult.Error); + } + + [Fact] + public void Allows_When_NoPrivateKey() + { + // Arrange + var sut = new ClientVersionValidator(MakeContext(new Version("2025.11.0"))); + var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext(); + var user = MakeValidV2User(); + user.PrivateKey = null; + + // Act + var ok = sut.Validate(user, ctx); + + // Assert + Assert.True(ok); + } + + [Fact] + public void Allows_When_NoSecurityVersion() + { + // Arrange + var sut = new ClientVersionValidator(MakeContext(new Version("2025.11.0"))); + var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext(); + + var user = MakeValidV2User(); + user.SecurityVersion = null; + // Act + var ok = sut.Validate(user, ctx); + // Assert + Assert.True(ok); + } + + [Fact] + public void Blocks_When_ClientVersionHeaderMissing() + { + // Arrange + var sut = new ClientVersionValidator(MakeContext(null)); + var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext(); + var user = MakeValidV2User(); + + // Act + var ok = sut.Validate(user, ctx); + + // Assert + Assert.False(ok); + Assert.NotNull(ctx.ValidationErrorResult); + Assert.True(ctx.ValidationErrorResult.IsError); + Assert.Equal("version_header_missing", ctx.ValidationErrorResult.Error); + } +} diff --git a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs index ac27c55466..52b8a2a4a2 100644 --- a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs +++ b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs @@ -67,7 +67,8 @@ IBaseRequestValidatorTestWrapper IPolicyRequirementQuery policyRequirementQuery, IAuthRequestRepository authRequestRepository, IMailService mailService, - IUserAccountKeysQuery userAccountKeysQuery) : + IUserAccountKeysQuery userAccountKeysQuery, + IClientVersionValidator clientVersionValidator) : base( userManager, userService, @@ -87,7 +88,8 @@ IBaseRequestValidatorTestWrapper policyRequirementQuery, authRequestRepository, mailService, - userAccountKeysQuery) + userAccountKeysQuery, + clientVersionValidator) { } diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index e190dda427..af60cadce4 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -12,6 +12,8 @@ using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Services; using Bit.Identity; +using Bit.Identity.IdentityServer; +using Bit.Identity.IdentityServer.RequestValidators; using Bit.Test.Common.Helpers; using LinqToDB; using Microsoft.AspNetCore.Hosting; @@ -27,6 +29,7 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase public const string DefaultUserEmail = "DefaultEmail@bitwarden.com"; public const string DefaultUserPasswordHash = "default_password_hash"; private const string DefaultEncryptedString = "2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg="; + public bool UseMockClientVersionValidator { get; set; } = true; /// /// A dictionary to store registration tokens for email verification. We cannot substitute the IMailService more than once, so @@ -50,6 +53,16 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase }); }); + if (UseMockClientVersionValidator) + { + // Bypass client version gating to isolate tests from client version behavior + SubstituteService(svc => + { + svc.Validate(Arg.Any(), Arg.Any()) + .Returns(true); + }); + } + base.ConfigureWebHost(builder); } @@ -296,7 +309,7 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase { try { - if (ctx?.Response?.Body == null) + if (ctx?.Response.Body == null) { return ""; }