1
0
mirror of https://github.com/bitwarden/server synced 2026-02-25 00:52:57 +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

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