1
0
mirror of https://github.com/bitwarden/server synced 2025-12-26 05:03:18 +00:00

feat(auth-validator): [PM-22975] Client Version Validator - initial implementation

This commit is contained in:
Patrick Pimentel
2025-11-17 15:46:02 -05:00
parent d1fecc2a0f
commit 1c4fd6ca24
20 changed files with 769 additions and 31 deletions

View File

@@ -0,0 +1,34 @@
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Queries;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Xunit;
namespace Bit.Core.Test.KeyManagement.Queries;
public class GetMinimumClientVersionForUserQueryTests
{
private class FakeIsV2Query : IIsV2EncryptionUserQuery
{
private readonly bool _isV2;
public FakeIsV2Query(bool isV2) { _isV2 = isV2; }
public Task<bool> Run(User user) => Task.FromResult(_isV2);
}
[Fact]
public async Task Run_ReturnsMinVersion_ForV2User()
{
var sut = new GetMinimumClientVersionForUserQuery(new FakeIsV2Query(true));
var version = await sut.Run(new User());
Assert.Equal(Core.KeyManagement.Constants.MinimumClientVersion, version);
}
[Fact]
public async Task Run_ReturnsNull_ForV1User()
{
var sut = new GetMinimumClientVersionForUserQuery(new FakeIsV2Query(false));
var version = await sut.Run(new User());
Assert.Null(version);
}
}

View File

@@ -0,0 +1,65 @@
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Entities;
using Bit.Core.KeyManagement.Enums;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Queries;
using Bit.Core.KeyManagement.Repositories;
using Bit.Core.KeyManagement.UserKey;
using Xunit;
namespace Bit.Core.Test.KeyManagement.Queries;
public class IsV2EncryptionUserQueryTests
{
private class FakeSigRepo : IUserSignatureKeyPairRepository
{
private readonly bool _hasKeys;
public FakeSigRepo(bool hasKeys) { _hasKeys = hasKeys; }
public Task<SignatureKeyPairData?> GetByUserIdAsync(Guid userId)
=> Task.FromResult(_hasKeys ? new SignatureKeyPairData(SignatureAlgorithm.Ed25519, "7.cose_signing", "vk") : null);
// Unused in tests
public Task<IEnumerable<UserSignatureKeyPair>> GetManyAsync(IEnumerable<Guid> ids) => throw new NotImplementedException();
public Task<UserSignatureKeyPair> GetByIdAsync(Guid id) => throw new NotImplementedException();
public Task<UserSignatureKeyPair> CreateAsync(UserSignatureKeyPair obj) => throw new NotImplementedException();
public Task ReplaceAsync(UserSignatureKeyPair obj) => throw new NotImplementedException();
public Task UpsertAsync(UserSignatureKeyPair obj) => throw new NotImplementedException();
public Task DeleteAsync(UserSignatureKeyPair obj) => throw new NotImplementedException();
public Task DeleteAsync(Guid id) => throw new NotImplementedException();
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid grantorId, SignatureKeyPairData signatureKeyPair) => throw new NotImplementedException();
public UpdateEncryptedDataForKeyRotation SetUserSignatureKeyPair(Guid userId, SignatureKeyPairData signatureKeyPair) => throw new NotImplementedException();
}
[Fact]
public async Task Run_ReturnsTrue_ForV2State()
{
var user = new User { Id = Guid.NewGuid(), PrivateKey = "7.cose" };
var sut = new IsV2EncryptionUserQuery(new FakeSigRepo(true));
var result = await sut.Run(user);
Assert.True(result);
}
[Fact]
public async Task Run_ReturnsFalse_ForV1State()
{
var user = new User { Id = Guid.NewGuid(), PrivateKey = "2.iv|ct|mac" };
var sut = new IsV2EncryptionUserQuery(new FakeSigRepo(false));
var result = await sut.Run(user);
Assert.False(result);
}
[Fact]
public async Task Run_ThrowsForInvalidMixedState()
{
var user = new User { Id = Guid.NewGuid(), PrivateKey = "7.cose" };
var sut = new IsV2EncryptionUserQuery(new FakeSigRepo(false));
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sut.Run(user));
}
}

View File

@@ -0,0 +1,119 @@
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 Microsoft.EntityFrameworkCore;
using Xunit;
namespace Bit.Identity.IntegrationTest.Login;
public class ClientVersionGateTests : IClassFixture<IdentityApplicationFactory>
{
private readonly IdentityApplicationFactory _factory;
public ClientVersionGateTests(IdentityApplicationFactory factory)
{
_factory = factory;
ReinitializeDbForTests(_factory);
}
[Theory, BitAutoData, RegisterFinishRequestModelCustomize]
public async Task TokenEndpoint_GrantTypePassword_V2User_OnOldClientVersion_Blocked(RegisterFinishRequestModel requestModel)
{
var localFactory = new IdentityApplicationFactory();
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 = "7.cose";
db.UserSignatureKeyPairs.Add(new Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair
{
Id = Core.Utilities.CoreHelpers.GenerateComb(),
UserId = efUser.Id,
SignatureAlgorithm = SignatureAlgorithm.Ed25519,
SigningKey = "7.cose_signing",
VerifyingKey = "vk"
});
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)
{
var localFactory = new IdentityApplicationFactory();
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 = "7.cose";
db.UserSignatureKeyPairs.Add(new Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair
{
Id = Core.Utilities.CoreHelpers.GenerateComb(),
UserId = efUser.Id,
SignatureAlgorithm = SignatureAlgorithm.Ed25519,
SigningKey = "7.cose_signing",
VerifyingKey = "vk"
});
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();
}
}

View File

@@ -0,0 +1,55 @@
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Identity.IdentityServer.RequestValidators;
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 IGetMinimumClientVersionForUserQuery MakeMinQuery(Version? v)
{
var q = Substitute.For<IGetMinimumClientVersionForUserQuery>();
q.Run(Arg.Any<User>()).Returns(Task.FromResult(v));
return q;
}
[Fact]
public async Task Allows_When_NoMinVersion()
{
var sut = new ClientVersionValidator(MakeContext(new Version("2025.1.0")), MakeMinQuery(null));
var ok = await sut.ValidateAsync(new User(), new Bit.Identity.IdentityServer.CustomValidatorRequestContext());
Assert.True(ok);
}
[Fact]
public async Task Blocks_When_ClientTooOld()
{
var sut = new ClientVersionValidator(MakeContext(new Version("2025.10.0")), MakeMinQuery(new Version("2025.11.0")));
var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext();
var ok = await sut.ValidateAsync(new User(), ctx);
Assert.False(ok);
Assert.NotNull(ctx.ValidationErrorResult);
Assert.True(ctx.ValidationErrorResult.IsError);
Assert.Equal("invalid_grant", ctx.ValidationErrorResult.Error);
}
[Fact]
public async Task Allows_When_ClientMeetsMin()
{
var sut = new ClientVersionValidator(MakeContext(new Version("2025.11.0")), MakeMinQuery(new Version("2025.11.0")));
var ok = await sut.ValidateAsync(new User(), new Bit.Identity.IdentityServer.CustomValidatorRequestContext());
Assert.True(ok);
}
}