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:
committed by
GitHub
parent
b5554c6030
commit
3dbd17f61d
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
149
test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs
Normal file
149
test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user