From b3b1b9b91dc15bb4958fb4676a12db49f6915b13 Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Mon, 1 Dec 2025 17:49:09 -0500 Subject: [PATCH] fix(auth-validator): [PM-22975] Client Version Validator - misc changes, trying to get things to work --- .../Factories/ApiApplicationFactory.cs | 7 +- .../Constants/TestEncryptionConstants.cs | 23 +++++ .../AutoFixture/GlobalSettingsFixtures.cs | 5 +- .../Queries/IsV2EncryptionUserQueryTests.cs | 9 +- .../Endpoints/IdentityServerSsoTests.cs | 96 ++++++++++++++++--- .../WebApplicationFactoryExtensions.cs | 3 + 6 files changed, 119 insertions(+), 24 deletions(-) create mode 100644 test/Common/Constants/TestEncryptionConstants.cs diff --git a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs index 08bd602537..93ebacf726 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 = "pk_test", - EncryptedPrivateKey = "2.iv|ct|mac" // v1-format so parsing succeeds and user is treated as v1 + PublicKey = TestEncryptionConstants.PublicKey, + EncryptedPrivateKey = TestEncryptionConstants.V1EncryptedBase64 // v1-format so parsing succeeds and user is treated as v1 }, - UserSymmetricKey = "2.iv|ct|mac", + UserSymmetricKey = TestEncryptionConstants.V1EncryptedBase64, }); 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..40ce984f23 --- /dev/null +++ b/test/Common/Constants/TestEncryptionConstants.cs @@ -0,0 +1,23 @@ +namespace Bit.Test.Common.Constants; + +public static class TestEncryptionConstants +{ + // V1-style encrypted strings (AES-CBC-HMAC formats) accepted by validators + public const string V1EncryptedBase64 = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA=="; + + // 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 = "test-wrapped-signing-key"; + public const string V2VerifyingKey = "test-verifying-key"; + // Additional related v2 values used in tests + public const string V2PublicKey = "test-public-key"; + public const string V2WrappedPrivateKey = "test-private-key"; + public const string V2SignedPublicKey = "test-signed-public-key"; +} + + 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/Queries/IsV2EncryptionUserQueryTests.cs b/test/Core.Test/KeyManagement/Queries/IsV2EncryptionUserQueryTests.cs index a3e91bb6ef..8dc7410033 100644 --- a/test/Core.Test/KeyManagement/Queries/IsV2EncryptionUserQueryTests.cs +++ b/test/Core.Test/KeyManagement/Queries/IsV2EncryptionUserQueryTests.cs @@ -5,6 +5,7 @@ using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Queries; using Bit.Core.KeyManagement.Repositories; using Bit.Core.KeyManagement.UserKey; +using Bit.Test.Common.Constants; using Xunit; namespace Bit.Core.Test.KeyManagement.Queries; @@ -16,7 +17,7 @@ public class IsV2EncryptionUserQueryTests private readonly bool _hasKeys; public FakeSigRepo(bool hasKeys) { _hasKeys = hasKeys; } public Task GetByUserIdAsync(Guid userId) - => Task.FromResult(_hasKeys ? new SignatureKeyPairData(SignatureAlgorithm.Ed25519, "7.cose_signing", "vk") : null); + => Task.FromResult(_hasKeys ? new SignatureKeyPairData(SignatureAlgorithm.Ed25519, TestEncryptionConstants.V2WrappedSigningKey, TestEncryptionConstants.V2VerifyingKey) : null); // Unused in tests public Task> GetManyAsync(IEnumerable ids) => throw new NotImplementedException(); @@ -33,7 +34,7 @@ public class IsV2EncryptionUserQueryTests [Fact] public async Task Run_ReturnsTrue_ForV2State() { - var user = new User { Id = Guid.NewGuid(), PrivateKey = "7.cose" }; + var user = new User { Id = Guid.NewGuid(), PrivateKey = TestEncryptionConstants.V2PrivateKey }; var sut = new IsV2EncryptionUserQuery(new FakeSigRepo(true)); var result = await sut.Run(user); @@ -44,7 +45,7 @@ public class IsV2EncryptionUserQueryTests [Fact] public async Task Run_ReturnsFalse_ForV1State() { - var user = new User { Id = Guid.NewGuid(), PrivateKey = "2.iv|ct|mac" }; + var user = new User { Id = Guid.NewGuid(), PrivateKey = TestEncryptionConstants.V1EncryptedBase64 }; var sut = new IsV2EncryptionUserQuery(new FakeSigRepo(false)); var result = await sut.Run(user); @@ -55,7 +56,7 @@ public class IsV2EncryptionUserQueryTests [Fact] public async Task Run_ThrowsForInvalidMixedState() { - var user = new User { Id = Guid.NewGuid(), PrivateKey = "7.cose" }; + var user = new User { Id = Guid.NewGuid(), PrivateKey = TestEncryptionConstants.V2PrivateKey }; var sut = new IsV2EncryptionUserQuery(new FakeSigRepo(false)); await Assert.ThrowsAsync(async () => await sut.Run(user)); diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs index 920d3b0ad3..2227428824 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.V1EncryptedBase64; + const string expectedUserKey = TestEncryptionConstants.V1EncryptedBase64; 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.V1EncryptedBase64, EncryptedUserKey = expectedUserKey, }); @@ -339,7 +342,8 @@ public class IdentityServerSsoTests { "code", "test_code" }, { "code_verifier", challenge }, { "redirect_uri", "https://localhost:8080/sso-connector.html" } - })); + }), + http => { http.Request.Headers.Append("Bitwarden-Client-Version", "2025.11.0"); }); // Assert // If the organization has selected TrustedDeviceEncryption but the user still has their master password @@ -408,7 +412,12 @@ public class IdentityServerSsoTests { "code", "test_code" }, { "code_verifier", challenge }, { "redirect_uri", "https://localhost:8080/sso-connector.html" } - })); + }), + http => + { + http.Request.Headers.Append("Bitwarden-Client-Version", "2025.11.0"); + http.Request.Headers.Append("Accept", "application/json"); + }); // Assert // If the organization has selected TrustedDeviceEncryption but the user still has their master password @@ -481,7 +490,8 @@ public class IdentityServerSsoTests { "code", "test_code" }, { "code_verifier", challenge }, { "redirect_uri", "https://localhost:8080/sso-connector.html" } - })); + }), + http => { http.Request.Headers.Append("Bitwarden-Client-Version", "2025.11.0"); }); Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); using var responseBody = await AssertHelper.AssertResponseTypeIs(context); @@ -558,10 +568,56 @@ public class IdentityServerSsoTests { "code", "test_code" }, { "code_verifier", challenge }, { "redirect_uri", "https://localhost:8080/sso-connector.html" } - })); + }), + http => { http.Request.Headers.Append("Bitwarden-Client-Version", "2025.11.0"); }); - // Only calls that result in a 200 OK should call this helper - Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + // If this fails, surface detailed error information to aid debugging + if (context.Response.StatusCode != StatusCodes.Status200OK) + { + 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); + } return await AssertHelper.AssertResponseTypeIs(context); } @@ -574,6 +630,18 @@ public class IdentityServerSsoTests { var factory = new IdentityApplicationFactory(); + // Bypass client version gating to isolate SSO test behavior + factory.SubstituteService(svc => + { + svc.ValidateAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(true)); + }); + + // Compute PKCE S256 code challenge explicitly (base64url of SHA256) + var challengeBytes = System.Text.Encoding.ASCII.GetBytes(challenge); + var hash = System.Security.Cryptography.SHA256.HashData(challengeBytes); + var codeChallenge = Duende.IdentityModel.Base64Url.Encode(hash); + var authorizationCode = new AuthorizationCode { ClientId = "web", @@ -581,8 +649,8 @@ public class IdentityServerSsoTests Lifetime = (int)TimeSpan.FromMinutes(5).TotalSeconds, RedirectUri = "https://localhost:8080/sso-connector.html", RequestedScopes = ["api", "offline_access"], - CodeChallenge = challenge.Sha256(), - CodeChallengeMethod = "plain", + CodeChallenge = codeChallenge, + CodeChallengeMethod = "S256", Subject = null!, // Temporarily set it to null }; @@ -601,10 +669,10 @@ public class IdentityServerSsoTests KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserAsymmetricKeys = new KeysRequestModel() { - PublicKey = "public_key", - EncryptedPrivateKey = "private_key" + PublicKey = TestEncryptionConstants.PublicKey, + EncryptedPrivateKey = TestEncryptionConstants.V1EncryptedBase64 // v1-format so parsing succeeds and user is treated as v1 }, - UserSymmetricKey = "sym_key", + UserSymmetricKey = TestEncryptionConstants.V1EncryptedBase64, }); var organizationRepository = factory.Services.GetRequiredService(); diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryExtensions.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryExtensions.cs index 3f5bf49dd9..5613f2e683 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryExtensions.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryExtensions.cs @@ -23,6 +23,9 @@ public static class WebApplicationFactoryExtensions // it runs after this so it will take precedence. httpContext.Connection.RemoteIpAddress = IPAddress.Parse(FactoryConstants.WhitelistedIp); + // Ensure response body is bufferable and seekable for tests to read later + httpContext.Response.Body = new MemoryStream(); + httpContext.Request.Path = new PathString(requestUri); httpContext.Request.Method = method.Method;