1
0
mirror of https://github.com/bitwarden/server synced 2026-02-24 08:33:06 +00:00

feat(auth-validator): [Auth/PM-22975] Client Version Validator (#6588)

* feat(auth-validator): [PM-22975] Client Version Validator - Implementation.

* test(auth-validator): [PM-22975] Client Version Validator - Added tests.
This commit is contained in:
Patrick-Pimentel-Bitwarden
2026-02-23 10:00:10 -05:00
committed by GitHub
parent b5554c6030
commit 3dbd17f61d
27 changed files with 732 additions and 83 deletions

View File

@@ -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<Startup>
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);

View File

@@ -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";
}

View File

@@ -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<IDataProtector>();
dataProtector.Unprotect(Arg.Any<byte[]>())
.Returns(data =>
Encoding.UTF8.GetBytes(Constants.DatabaseFieldProtectedPrefix +
Encoding.UTF8.GetBytes(Core.Constants.DatabaseFieldProtectedPrefix +
Encoding.UTF8.GetString((byte[])data[0])));
var dataProtectionProvider = Substitute.For<IDataProtectionProvider>();
dataProtectionProvider.CreateProtector(Constants.DatabaseFieldProtectorPurpose)
dataProtectionProvider.CreateProtector(Core.Constants.DatabaseFieldProtectorPurpose)
.Returns(dataProtector);
return dataProtectionProvider;

View File

@@ -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<ArgumentNullException>(() => 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<ArgumentException>(() => 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);
}
}

View File

@@ -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<Startup>
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);

View File

@@ -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<IUserRepository>().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<string, string>
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "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 = "<unreadable>";
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<IClientVersionValidator>(svc =>
{
svc.Validate(Arg.Any<User>(), Arg.Any<CustomValidatorRequestContext>())
.Returns(true);
});
var authorizationCode = new AuthorizationCode
{
ClientId = "web",
@@ -584,6 +643,7 @@ public class IdentityServerSsoTests
factory.SubstituteService<IAuthorizationCodeStore>(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<IOrganizationRepository>();

View File

@@ -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<IdentityApplicationFactory>
public IdentityServerTests(IdentityApplicationFactory factory)
{
_factory = factory;
// Bypass client version gating to isolate SSO test behavior
_factory.SubstituteService<IClientVersionValidator>(svc =>
{
svc.Validate(Arg.Any<User>(), Arg.Any<CustomValidatorRequestContext>())
.Returns(true);
});
ReinitializeDbForTests(_factory);
}

View File

@@ -387,10 +387,10 @@ public class IdentityServerTwoFactorTests : IClassFixture<IdentityApplicationFac
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
UserAsymmetricKeys = new KeysRequestModel()
{
PublicKey = "public_key",
EncryptedPrivateKey = "private_key"
PublicKey = Bit.Test.Common.Constants.TestEncryptionConstants.PublicKey,
EncryptedPrivateKey = Bit.Test.Common.Constants.TestEncryptionConstants.AES256_CBC_HMAC_Encstring
},
UserSymmetricKey = "sym_key",
UserSymmetricKey = Bit.Test.Common.Constants.TestEncryptionConstants.AES256_CBC_HMAC_Encstring,
});
Assert.NotNull(user);
@@ -441,10 +441,10 @@ public class IdentityServerTwoFactorTests : IClassFixture<IdentityApplicationFac
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
UserAsymmetricKeys = new KeysRequestModel()
{
PublicKey = "public_key",
EncryptedPrivateKey = "private_key"
PublicKey = Bit.Test.Common.Constants.TestEncryptionConstants.PublicKey,
EncryptedPrivateKey = Bit.Test.Common.Constants.TestEncryptionConstants.AES256_CBC_HMAC_Encstring
},
UserSymmetricKey = "sym_key",
UserSymmetricKey = Bit.Test.Common.Constants.TestEncryptionConstants.AES256_CBC_HMAC_Encstring,
});
var userService = factory.GetService<IUserService>();

View File

@@ -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<IdentityApplicationFactory>
{
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<string, string>
{
{ "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<JsonDocument>(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<string, string>
{
{ "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";
}
}
}

View File

@@ -613,10 +613,10 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
UserAsymmetricKeys = new KeysRequestModel
{
PublicKey = "public_key",
EncryptedPrivateKey = "private_key"
PublicKey = Bit.Test.Common.Constants.TestEncryptionConstants.PublicKey,
EncryptedPrivateKey = Bit.Test.Common.Constants.TestEncryptionConstants.AES256_CBC_HMAC_Encstring
},
UserSymmetricKey = "sym_key",
UserSymmetricKey = Bit.Test.Common.Constants.TestEncryptionConstants.AES256_CBC_HMAC_Encstring,
});
}

View File

@@ -58,6 +58,7 @@ public class BaseRequestValidatorTests
private readonly IAuthRequestRepository _authRequestRepository;
private readonly IMailService _mailService;
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
private readonly IClientVersionValidator _clientVersionValidator;
private readonly BaseRequestValidatorTestWrapper _sut;
@@ -82,6 +83,7 @@ public class BaseRequestValidatorTests
_authRequestRepository = Substitute.For<IAuthRequestRepository>();
_mailService = Substitute.For<IMailService>();
_userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>();
_clientVersionValidator = Substitute.For<IClientVersionValidator>();
_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<User>(), Arg.Any<CustomValidatorRequestContext>())
.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<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
.Returns(Task.FromResult(true));
// Act
await _sut.ValidateAsync(context);
// Assert
_clientVersionValidator.Received(1)
.Validate(requestContext.User, requestContext);
}
/// <summary>
/// Tests that when SSO validation returns a custom response, (e.g., with organization identifier),
/// that custom response is properly propagated to the result.

View File

@@ -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<ICurrentContext>();
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);
}
}

View File

@@ -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)
{
}

View File

@@ -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<Startup>
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;
/// <summary>
/// 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<Startup>
});
});
if (UseMockClientVersionValidator)
{
// Bypass client version gating to isolate tests from client version behavior
SubstituteService<IClientVersionValidator>(svc =>
{
svc.Validate(Arg.Any<User>(), Arg.Any<CustomValidatorRequestContext>())
.Returns(true);
});
}
base.ConfigureWebHost(builder);
}
@@ -296,7 +309,7 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase<Startup>
{
try
{
if (ctx?.Response?.Body == null)
if (ctx?.Response.Body == null)
{
return "<no body>";
}