mirror of
https://github.com/bitwarden/server
synced 2026-01-02 00:23:40 +00:00
fix(auth-validator): [PM-22975] Client Version Validator - misc changes, trying to get things to work
This commit is contained in:
@@ -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 = "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);
|
||||
|
||||
23
test/Common/Constants/TestEncryptionConstants.cs
Normal file
23
test/Common/Constants/TestEncryptionConstants.cs
Normal file
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SignatureKeyPairData?> 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<IEnumerable<UserSignatureKeyPair>> GetManyAsync(IEnumerable<Guid> 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<InvalidOperationException>(async () => await sut.Run(user));
|
||||
|
||||
@@ -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.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<JsonDocument>(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 = "<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);
|
||||
}
|
||||
|
||||
return await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
|
||||
}
|
||||
@@ -574,6 +630,18 @@ public class IdentityServerSsoTests
|
||||
{
|
||||
var factory = new IdentityApplicationFactory();
|
||||
|
||||
// Bypass client version gating to isolate SSO test behavior
|
||||
factory.SubstituteService<IClientVersionValidator>(svc =>
|
||||
{
|
||||
svc.ValidateAsync(Arg.Any<User>(), Arg.Any<CustomValidatorRequestContext>())
|
||||
.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<IOrganizationRepository>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user