From a9171a1749651dbc6c1a205e04e4ba065d4f0956 Mon Sep 17 00:00:00 2001 From: jeff Date: Tue, 3 Nov 2026 18:09:42 +0800 Subject: [PATCH 1/7] =?UTF-8?q?Target=20.NET=2010=20and=20fix=20Keycloak?= =?UTF-8?q?=20flows=20/=20=E4=BB=85=E6=94=AF=E6=8C=81=20.NET=2010=20?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=E9=9B=86=E6=88=90=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit English - Target net10.0 exclusively in the client and test projects; remove net8.0/net9.0 dependency branches and legacy authentication package references. - Upgrade runtime and test dependencies, and adapt MSTest 4 assertions and PasswordGenerator 3 test fixtures. - Scope RPT bearer tokens to individual HTTP requests so shared HttpClient defaults cannot contaminate subsequent callers. - Preserve organization JSON field names with Newtonsoft.Json and System.Text.Json mappings; correct the initial-access token creation assertion to HTTP 201. - Add regression coverage for request isolation, organization serialization, password generation, and JWT session parsing; update CI and documentation for .NET 10. Validation - Debug and Release solution builds passed. - Full .NET 10 suite against isolated Keycloak 26.8.1: 339 passed, 0 failed, and 5 existing version-specific skips (344 total). - Generated NuGet package contains only lib/net10.0 assets. - Dependency vulnerability scan and whitespace checks passed. 简体中文 - 客户端及测试项目统一仅支持 net10.0,删除 net8.0/net9.0 条件依赖分支及旧版认证包引用。 - 升级运行时与测试依赖,适配 MSTest 4 异常断言和 PasswordGenerator 3 测试数据生成接口。 - 将 RPT Bearer 令牌限定在单个 HTTP 请求中,修复共享 HttpClient 默认请求头污染后续用户请求的问题。 - 为组织模型保留 Newtonsoft.Json 与 System.Text.Json 两种字段映射,并将初始访问令牌创建断言修正为 HTTP 201。 - 新增请求隔离、组织序列化、密码生成及 JWT 会话解析回归测试,同步更新 .NET 10 的 CI 配置和文档。 验证结果 - Debug、Release 解决方案编译通过。 - 在隔离的 Keycloak 26.8.1 环境完整运行 .NET 10 测试:339 项通过、0 失败、5 项原有版本限制跳过,共 344 项。 - 生成的 NuGet 包仅包含 lib/net10.0 产物。 - 依赖漏洞扫描及空白检查通过。 --- .github/workflows/build_test_analyze.yml | 9 +-- .../ExceptionsTests/KcExecptionTests.cs | 4 +- .../KcSessionClosedExceptionTests.cs | 4 +- .../KcUserNotFoundExceptionTests.cs | 4 +- .../MockData/KcTestPasswordCreator.cs | 6 +- .../KcAuthClientCredentialsTests.cs | 4 +- .../KcAuthTests/KcAuthRequestPartyToken.cs | 1 + .../KcAuthRequestPartyTokenUnitTests.cs | 54 ++++++++++++++ .../KcAuthResourceOwnerPasswordTests.cs | 8 +-- .../KcAuthenticationConfigurationTests.cs | 9 +-- .../KcAuthenticationExtensionTests.cs | 5 +- .../KcRolesClaimsTransformerTests.cs | 4 +- .../KcAuthorizationExtensionTests.cs | 4 +- .../KcAuthorizationRequirementTests.cs | 6 +- .../KcBearerAuthorizationHandlerTests.cs | 12 ++-- .../KcProtectedResourcePolicyProviderTests.cs | 2 +- .../KcRealmAdminConfigurationTests.cs | 4 +- .../KcRealmAdminTokenHandlerTests.cs | 8 +-- .../Modules/KcClientInitialAccessTests.cs | 4 +- .../Modules/KcClientTests.cs | 2 +- .../KcClientsTests/KcClientHappyPathTests.cs | 2 +- .../Modules/KcCommonAssertion.cs | 6 +- .../Modules/KcCommonTests.cs | 2 +- .../KcGroupTests/KcGroupsHappyPathTests.cs | 2 +- .../KcOrganizationHappyPathTests.cs | 4 +- .../KcOrganizationSerializationTests.cs | 49 +++++++++++++ .../KcUserTests/KcUserHappyPathTests.cs | 2 +- .../NETCore.Keycloak.Client.Tests.csproj | 16 ++--- NETCore.Keycloak.Client.Tests/README.md | 27 ++++++- .../KcDependencyCompatibilityTests.cs | 71 +++++++++++++++++++ .../HttpClients/Implementation/KcAuth.cs | 15 ++-- .../Models/Organizations/KcOrganization.cs | 9 +++ .../Organizations/KcOrganizationDomain.cs | 3 + .../NETCore.Keycloak.Client.csproj | 38 ++-------- NETCore.Keycloak.Client/README.md | 4 +- README.md | 4 +- 36 files changed, 293 insertions(+), 115 deletions(-) create mode 100644 NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthRequestPartyTokenUnitTests.cs create mode 100644 NETCore.Keycloak.Client.Tests/Modules/KcOrganizationTests/KcOrganizationSerializationTests.cs create mode 100644 NETCore.Keycloak.Client.Tests/UtilsTests/KcDependencyCompatibilityTests.cs diff --git a/.github/workflows/build_test_analyze.yml b/.github/workflows/build_test_analyze.yml index f4f2f4b..acebdae 100644 --- a/.github/workflows/build_test_analyze.yml +++ b/.github/workflows/build_test_analyze.yml @@ -36,14 +36,11 @@ jobs: with: python-version: '3.12' - # Install all required .NET SDK versions - - name: Install .NET SDKs (8.0, 9.0 and 10.0) + # Install the supported .NET SDK + - name: Install .NET 10 SDK uses: actions/setup-dotnet@v1 with: - dotnet-version: | - 8.0.x - 9.0.x - 10.0.x + dotnet-version: 10.0.x # Install dependencies - name: Install dependencies diff --git a/NETCore.Keycloak.Client.Tests/ExceptionsTests/KcExecptionTests.cs b/NETCore.Keycloak.Client.Tests/ExceptionsTests/KcExecptionTests.cs index 48fec3f..56246ba 100644 --- a/NETCore.Keycloak.Client.Tests/ExceptionsTests/KcExecptionTests.cs +++ b/NETCore.Keycloak.Client.Tests/ExceptionsTests/KcExecptionTests.cs @@ -13,7 +13,7 @@ public class KcExceptionTests /// Verifies that the exception is correctly identified. /// [TestMethod] - public void ShouldThrowKcException() => Assert.ThrowsException(() => throw new KcException()); + public void ShouldThrowKcException() => _ = Assert.ThrowsExactly(() => throw new KcException()); /// /// Tests that a with a specified message retains the message correctly. @@ -43,6 +43,6 @@ public void ShouldThrowKcExceptionWithInnerException() // Verify the inner exception Assert.IsNotNull(exception.InnerException); - Assert.IsInstanceOfType(exception.InnerException); + _ = Assert.IsInstanceOfType(exception.InnerException); } } diff --git a/NETCore.Keycloak.Client.Tests/ExceptionsTests/KcSessionClosedExceptionTests.cs b/NETCore.Keycloak.Client.Tests/ExceptionsTests/KcSessionClosedExceptionTests.cs index 25cde86..05557fe 100644 --- a/NETCore.Keycloak.Client.Tests/ExceptionsTests/KcSessionClosedExceptionTests.cs +++ b/NETCore.Keycloak.Client.Tests/ExceptionsTests/KcSessionClosedExceptionTests.cs @@ -14,7 +14,7 @@ public class KcSessionClosedExceptionTests /// [TestMethod] public void ShouldThrowKcSessionClosedException() => - Assert.ThrowsException(() => throw new KcSessionClosedException()); + _ = Assert.ThrowsExactly(() => throw new KcSessionClosedException()); /// /// Tests that a with a specified message retains the message correctly. @@ -44,6 +44,6 @@ public void ShouldThrowKcSessionClosedExceptionWithInnerException() // Verify the inner exception Assert.IsNotNull(exception.InnerException); - Assert.IsInstanceOfType(exception.InnerException); + _ = Assert.IsInstanceOfType(exception.InnerException); } } diff --git a/NETCore.Keycloak.Client.Tests/ExceptionsTests/KcUserNotFoundExceptionTests.cs b/NETCore.Keycloak.Client.Tests/ExceptionsTests/KcUserNotFoundExceptionTests.cs index 2029f11..18f8b27 100644 --- a/NETCore.Keycloak.Client.Tests/ExceptionsTests/KcUserNotFoundExceptionTests.cs +++ b/NETCore.Keycloak.Client.Tests/ExceptionsTests/KcUserNotFoundExceptionTests.cs @@ -14,7 +14,7 @@ public class KcUserNotFoundExceptionTests /// [TestMethod] public void ShouldThrowKcUserNotFoundException() => - Assert.ThrowsException(() => throw new KcUserNotFoundException()); + _ = Assert.ThrowsExactly(() => throw new KcUserNotFoundException()); /// /// Tests that a with a specified message retains the message correctly. @@ -44,6 +44,6 @@ public void ShouldThrowKcUserNotFoundExceptionWithInnerException() // Verify the inner exception Assert.IsNotNull(exception.InnerException); - Assert.IsInstanceOfType(exception.InnerException); + _ = Assert.IsInstanceOfType(exception.InnerException); } } diff --git a/NETCore.Keycloak.Client.Tests/MockData/KcTestPasswordCreator.cs b/NETCore.Keycloak.Client.Tests/MockData/KcTestPasswordCreator.cs index 72c93a3..bd95306 100644 --- a/NETCore.Keycloak.Client.Tests/MockData/KcTestPasswordCreator.cs +++ b/NETCore.Keycloak.Client.Tests/MockData/KcTestPasswordCreator.cs @@ -20,15 +20,15 @@ public static string Create(int passwordLength = 16, bool lowerCase = true, bool bool digits = true, bool specialAscii = true) { // Configure password generation settings based on provided parameters. - var pwd = new Password + using var pwd = new Password { - Settings = new PasswordGeneratorSettings( + Settings = new PasswordSettings( lowerCase, upperCase, digits, specialAscii, passwordLength, - 8, // Minimum number of character types to include. + 8, // Maximum number of generation attempts. false) }; diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthClientCredentialsTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthClientCredentialsTests.cs index f4547b2..880e438 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthClientCredentialsTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthClientCredentialsTests.cs @@ -60,12 +60,12 @@ public async Task ShouldGetClientCredentialsToken() [TestMethod] public async Task ShouldValidateClientCredentials() { - _ = await Assert.ThrowsExceptionAsync( + _ = await Assert.ThrowsExactlyAsync( async () => await KeycloakRestClient.Auth .GetClientCredentialsTokenAsync(TestEnvironment.TestingRealm.Name, null).ConfigureAwait(false)) .ConfigureAwait(false); - _ = await Assert.ThrowsExceptionAsync( + _ = await Assert.ThrowsExactlyAsync( async () => await KeycloakRestClient.Auth .GetClientCredentialsTokenAsync(TestEnvironment.TestingRealm.Name, new KcClientCredentials()) .ConfigureAwait(false)) diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthRequestPartyToken.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthRequestPartyToken.cs index 293b579..e7633c3 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthRequestPartyToken.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthRequestPartyToken.cs @@ -279,6 +279,7 @@ public async Task D_ShouldGetRequestPartyToken() // Assert that the RPT response is valid. Assert.IsNotNull(rptResponse); + Assert.IsFalse(rptResponse.IsError, rptResponse.ErrorMessage); // Validate monitoring metrics for the RPT request. KcCommonAssertion.AssertResponseMonitoringMetrics( diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthRequestPartyTokenUnitTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthRequestPartyTokenUnitTests.cs new file mode 100644 index 0000000..d7ccb0d --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthRequestPartyTokenUnitTests.cs @@ -0,0 +1,54 @@ +using System.Net; +using System.Net.Http.Headers; +using Moq; +using NETCore.Keycloak.Client.HttpClients.Implementation; + +namespace NETCore.Keycloak.Client.Tests.Modules.KcAuthTests; + +/// +/// Verifies that RPT requests keep each caller's bearer token isolated. +/// +[TestClass] +public class KcAuthRequestPartyTokenUnitTests +{ + /// + /// Uses the current caller's token without changing shared client defaults. + /// + [TestMethod] + public async Task ShouldKeepBearerTokensScopedToEachRequest() + { + using var handler = new RecordingHandler(); + using var httpClient = new HttpClient(handler); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "factory-default"); + var factory = new Mock(); + _ = factory.Setup(value => value.CreateClient(It.IsAny())).Returns(httpClient); + var client = new KeycloakClient("https://keycloak.test", null, factory.Object); + + var first = await client.Auth.GetRequestPartyTokenAsync("test-realm", "first-token", "auth_client") + .ConfigureAwait(false); + var second = await client.Auth.GetRequestPartyTokenAsync("test-realm", "second-token", "auth_client") + .ConfigureAwait(false); + + Assert.IsFalse(first.IsError, first.ErrorMessage); + Assert.IsFalse(second.IsError, second.ErrorMessage); + CollectionAssert.AreEqual(new[] { "Bearer first-token" }, handler.AuthorizationHeaders[0]); + CollectionAssert.AreEqual(new[] { "Bearer second-token" }, handler.AuthorizationHeaders[1]); + Assert.AreEqual("factory-default", httpClient.DefaultRequestHeaders.Authorization?.Parameter); + } + + private sealed class RecordingHandler : HttpMessageHandler + { + public List AuthorizationHeaders { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + AuthorizationHeaders.Add(request.Headers.GetValues("Authorization").ToArray()); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"access_token\":\"rpt-token\",\"token_type\":\"Bearer\"}"), + RequestMessage = request + }); + } + } +} diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthResourceOwnerPasswordTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthResourceOwnerPasswordTests.cs index cf613a1..0077820 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthResourceOwnerPasswordTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthResourceOwnerPasswordTests.cs @@ -43,7 +43,7 @@ public async Task ShouldGetResourceOwnerPasswordToken() => public async Task ShouldValidateUserLogin() { // Assert that null login details throw an exception. - _ = await Assert.ThrowsExceptionAsync( + _ = await Assert.ThrowsExactlyAsync( async () => await KeycloakRestClient.Auth.GetResourceOwnerPasswordTokenAsync( TestEnvironment.TestingRealm.Name, new KcClientCredentials @@ -53,7 +53,7 @@ public async Task ShouldValidateUserLogin() .ConfigureAwait(false); // Assert that an empty login object throws an exception. - _ = await Assert.ThrowsExceptionAsync( + _ = await Assert.ThrowsExactlyAsync( async () => await KeycloakRestClient.Auth.GetResourceOwnerPasswordTokenAsync( TestEnvironment.TestingRealm.Name, new KcClientCredentials @@ -63,7 +63,7 @@ public async Task ShouldValidateUserLogin() .ConfigureAwait(false); // Assert that a login object with only a username throws an exception. - _ = await Assert.ThrowsExceptionAsync( + _ = await Assert.ThrowsExactlyAsync( async () => await KeycloakRestClient.Auth.GetResourceOwnerPasswordTokenAsync( TestEnvironment.TestingRealm.Name, new KcClientCredentials @@ -76,7 +76,7 @@ public async Task ShouldValidateUserLogin() .ConfigureAwait(false); // Assert that a login object with only a password throws an exception. - _ = await Assert.ThrowsExceptionAsync( + _ = await Assert.ThrowsExactlyAsync( async () => await KeycloakRestClient.Auth.GetResourceOwnerPasswordTokenAsync( TestEnvironment.TestingRealm.Name, new KcClientCredentials diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcAuthenticationConfigurationTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcAuthenticationConfigurationTests.cs index a8144ff..7944f53 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcAuthenticationConfigurationTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcAuthenticationConfigurationTests.cs @@ -172,7 +172,6 @@ public void AuthorityShouldReturnValidIssuer() /// is not set (null, empty, or whitespace). /// [TestMethod] - [ExpectedException(typeof(KcException))] public void ShouldValidateBaseUrl() { // Arrange @@ -185,7 +184,7 @@ public void ShouldValidateBaseUrl() // Act // Call the Validate method, which should throw an exception due to the missing Url. - config.Validate(); + _ = Assert.ThrowsExactly(config.Validate); } /// @@ -194,7 +193,6 @@ public void ShouldValidateBaseUrl() /// is not set (null, empty, or whitespace). /// [TestMethod] - [ExpectedException(typeof(KcException))] public void ShouldValidateRealm() { // Arrange @@ -207,7 +205,7 @@ public void ShouldValidateRealm() // Act // Call the Validate method, which should throw an exception due to the missing Realm. - config.Validate(); + _ = Assert.ThrowsExactly(config.Validate); } /// @@ -216,7 +214,6 @@ public void ShouldValidateRealm() /// is not set (null, empty, or whitespace). /// [TestMethod] - [ExpectedException(typeof(KcException))] public void ShouldValidateIssuer() { // Arrange @@ -229,7 +226,7 @@ public void ShouldValidateIssuer() // Act // Call the Validate method, which should throw an exception due to the missing Issuer. - config.Validate(); + _ = Assert.ThrowsExactly(config.Validate); } /// diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcAuthenticationExtensionTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcAuthenticationExtensionTests.cs index fdf761c..02035ad 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcAuthenticationExtensionTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcAuthenticationExtensionTests.cs @@ -55,9 +55,8 @@ public void ShouldUseDefaultAuthenticationSchemeWhenSchemeIsNull() /// throws a when the Keycloak configuration delegate is null. /// [TestMethod] - [ExpectedException(typeof(KcException))] public void ShouldThrowExceptionWhenKeycloakConfigIsNull() => - _services.AddKeycloakAuthentication("test-scheme", null); + _ = Assert.ThrowsExactly(() => _services.AddKeycloakAuthentication("test-scheme", null)); /// /// Verifies that the method @@ -88,7 +87,7 @@ public void ShouldRegisterClaimsTransformation() Assert.IsNotNull(claimsTransformer, "IClaimsTransformation should be registered."); // Verify that the registered IClaimsTransformation is of type KcRolesClaimsTransformer. - Assert.IsInstanceOfType(claimsTransformer, + _ = Assert.IsInstanceOfType(claimsTransformer, "Claims transformer should be of type KcRolesClaimsTransformer."); } diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcRolesClaimsTransformerTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcRolesClaimsTransformerTests.cs index 397ff48..ab0ae95 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcRolesClaimsTransformerTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcRolesClaimsTransformerTests.cs @@ -37,7 +37,6 @@ public class KcRolesClaimsTransformerTests /// throws an when the provided principal is null. /// [TestMethod] - [ExpectedException(typeof(ArgumentNullException))] public async Task ShouldThrowExceptionWhenPrincipalIsNull() { // Arrange @@ -47,7 +46,8 @@ public async Task ShouldThrowExceptionWhenPrincipalIsNull() // Act // Attempt to transform a null ClaimsPrincipal, which should throw an exception. // ReSharper disable once AssignNullToNotNullAttribute - _ = await transformer.TransformAsync(null).ConfigureAwait(false); + _ = await Assert.ThrowsExactlyAsync(() => transformer.TransformAsync(null)) + .ConfigureAwait(false); } /// diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcAuthorizationExtensionTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcAuthorizationExtensionTests.cs index a560210..799e938 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcAuthorizationExtensionTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcAuthorizationExtensionTests.cs @@ -88,7 +88,7 @@ public void ShouldRegisterAuthorizationHandler() // Verify that the authorization handler is registered and of the expected type. Assert.IsNotNull(authorizationHandler, "IAuthorizationHandler should be registered."); - Assert.IsInstanceOfType(authorizationHandler); + _ = Assert.IsInstanceOfType(authorizationHandler); // Verify that the HTTP context accessor is registered. Assert.IsNotNull(httpContextAccessor, "IHttpContextAccessor should be registered."); @@ -151,6 +151,6 @@ public void ShouldRegisterKeycloakProtectedResourcesPoliciesServices() Assert.IsNotNull(policyProvider, "IAuthorizationPolicyProvider should be registered."); // Verify that the IAuthorizationPolicyProvider is of the expected type. - Assert.IsInstanceOfType(policyProvider); + _ = Assert.IsInstanceOfType(policyProvider); } } diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcAuthorizationRequirementTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcAuthorizationRequirementTests.cs index da17079..d62150f 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcAuthorizationRequirementTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcAuthorizationRequirementTests.cs @@ -77,7 +77,7 @@ public void ToString_ShouldReturnFormattedString() /// [TestMethod] public void Constructor_ShouldThrowException_WhenProtectedResourceStoreIsNull() => - Assert.ThrowsException(() => + _ = Assert.ThrowsExactly(() => new KcAuthorizationRequirement(null, "TestResource", "TestScope")); /// @@ -90,7 +90,7 @@ public void Constructor_ShouldThrowException_WhenResourceIsNull() var mockProtectedResourceStore = new Mock(); // Act - _ = Assert.ThrowsException(() => + _ = Assert.ThrowsExactly(() => new KcAuthorizationRequirement(mockProtectedResourceStore.Object, null, "TestScope")); } @@ -104,7 +104,7 @@ public void Constructor_ShouldThrowException_WhenScopeIsNull() var mockProtectedResourceStore = new Mock(); // Act - _ = Assert.ThrowsException(() => + _ = Assert.ThrowsExactly(() => new KcAuthorizationRequirement(mockProtectedResourceStore.Object, "TestResource", null)); } } diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcBearerAuthorizationHandlerTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcBearerAuthorizationHandlerTests.cs index e7f8921..a06d7ca 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcBearerAuthorizationHandlerTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcBearerAuthorizationHandlerTests.cs @@ -191,7 +191,7 @@ public async Task B_ShouldValidateAuthorizationHandlerContext() Assert.IsNotNull(_handler); // Verify that TestHandleRequirementAsync throws ArgumentNullException for null arguments - _ = await Assert.ThrowsExceptionAsync(async () => + _ = await Assert.ThrowsExactlyAsync(async () => await _handler.TestHandleRequirementAsync(null, null).ConfigureAwait(false)) .ConfigureAwait(false); } @@ -219,7 +219,7 @@ public async Task C_ShouldValidateKcAuthorizationRequirement() ); // Verify that TestHandleRequirementAsync throws ArgumentNullException when the requirement is null - _ = await Assert.ThrowsExceptionAsync(async () => + _ = await Assert.ThrowsExactlyAsync(async () => await _handler.TestHandleRequirementAsync(mockAuthorizationHandlerContext, null).ConfigureAwait(false)) .ConfigureAwait(false); } @@ -344,7 +344,7 @@ public async Task FA_ShouldDenyAccessWithKcUserNotFoundException() _handler = new KcTestableBearerAuthorizationHandler(_mockProvider.Object); // Verify that TestHandleRequirementAsync throws KcUserNotFoundException for the given context and requirement - _ = await Assert.ThrowsExceptionAsync(async () => + _ = await Assert.ThrowsExactlyAsync(async () => await _handler.TestHandleRequirementAsync(context, requirement).ConfigureAwait(false)) .ConfigureAwait(false); } @@ -388,7 +388,7 @@ public async Task FB_ShouldDenyAccessWithKcUserNotFoundException() _handler = new KcTestableBearerAuthorizationHandler(_mockProvider.Object); // Verify that TestHandleRequirementAsync throws KcUserNotFoundException for the given context and requirement - _ = await Assert.ThrowsExceptionAsync(async () => + _ = await Assert.ThrowsExactlyAsync(async () => await _handler.TestHandleRequirementAsync(context, requirement).ConfigureAwait(false)) .ConfigureAwait(false); } @@ -431,7 +431,7 @@ public async Task GA_ShouldDenyAccessWithKcSessionClosedException() _handler = new KcTestableBearerAuthorizationHandler(_mockProvider.Object); // Verify that TestHandleRequirementAsync throws KcSessionClosedException for the given context and requirement - _ = await Assert.ThrowsExceptionAsync(async () => + _ = await Assert.ThrowsExactlyAsync(async () => await _handler.TestHandleRequirementAsync(context, requirement).ConfigureAwait(false)) .ConfigureAwait(false); } @@ -476,7 +476,7 @@ public async Task GB_ShouldDenyAccessWithKcSessionClosedException() _handler = new KcTestableBearerAuthorizationHandler(_mockProvider.Object); // Verify that TestHandleRequirementAsync throws KcSessionClosedException for the given context and requirement - _ = await Assert.ThrowsExceptionAsync(async () => + _ = await Assert.ThrowsExactlyAsync(async () => await _handler.TestHandleRequirementAsync(context, requirement).ConfigureAwait(false)) .ConfigureAwait(false); } diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcProtectedResourcePolicyProviderTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcProtectedResourcePolicyProviderTests.cs index e4028be..d3a8cc8 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcProtectedResourcePolicyProviderTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcProtectedResourcePolicyProviderTests.cs @@ -87,7 +87,7 @@ public async Task ShouldThrowsArgumentNullExceptionForNullOrEmptyPolicyName() string policyName = null; // Act & Assert - _ = await Assert.ThrowsExceptionAsync(async () => + _ = await Assert.ThrowsExactlyAsync(async () => // ReSharper disable once AssignNullToNotNullAttribute await _policyProvider.GetPolicyAsync(policyName).ConfigureAwait(false)).ConfigureAwait(false); } diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcRealmAdminConfigurationTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcRealmAdminConfigurationTests.cs index 132ed2d..ab1fc01 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcRealmAdminConfigurationTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcRealmAdminConfigurationTests.cs @@ -57,7 +57,7 @@ public void Validate_ShouldThrowException_WhenKeycloakBaseUrlIsInvalid(string in }; // Act & Assert - var exception = Assert.ThrowsException(configuration.Validate); + var exception = Assert.ThrowsExactly(configuration.Validate); Assert.AreEqual("KeycloakBaseUrl is required", exception.Message); } @@ -77,7 +77,7 @@ public void Validate_ShouldThrowException_WhenRealmAdminCredentialsAreNull() }; // Act & Assert - var exception = Assert.ThrowsException(configuration.Validate); + var exception = Assert.ThrowsExactly(configuration.Validate); Assert.AreEqual("RealmAdminCredentials is required", exception.Message); } diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcRealmAdminTokenHandlerTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcRealmAdminTokenHandlerTests.cs index 4ddbb30..9dbcb79 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcRealmAdminTokenHandlerTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcRealmAdminTokenHandlerTests.cs @@ -91,7 +91,7 @@ public void SetUp() /// Verifies that the constructor throws an when the configuration store is null. /// [TestMethod] - public void A_ShouldValidateRealmAdminConfigurationStore() => Assert.ThrowsException( + public void A_ShouldValidateRealmAdminConfigurationStore() => _ = Assert.ThrowsExactly( () => _ = new KcRealmAdminTokenHandler(null, _mockProvider.Object)); /// @@ -105,7 +105,7 @@ public void B_ShouldValidateRealmAdminConfigurationStoreCollection() .Setup(c => c.GetRealmsAdminConfiguration()) .Returns(() => null); - _ = Assert.ThrowsException(() => + _ = Assert.ThrowsExactly(() => _ = new KcRealmAdminTokenHandler(_mockConfigStore.Object, _mockProvider.Object)); } @@ -134,7 +134,7 @@ public async Task D_ShouldValidateRealmName() Assert.IsNotNull(_tokenHandler); - _ = await Assert.ThrowsExceptionAsync(async () => + _ = await Assert.ThrowsExactlyAsync(async () => await _tokenHandler.TryGetAdminTokenAsync(null).ConfigureAwait(false)) .ConfigureAwait(false); } @@ -172,7 +172,7 @@ public async Task E_ShouldNotCacheTokenWithInvalidConfiguration() Assert.IsNotNull(_tokenHandler); // Verify that a KcException is thrown due to the invalid configuration. - _ = await Assert.ThrowsExceptionAsync(async () => await _tokenHandler + _ = await Assert.ThrowsExactlyAsync(async () => await _tokenHandler .TryGetAdminTokenAsync(TestEnvironment.TestingRealm.Name).ConfigureAwait(false)).ConfigureAwait(false); } diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcClientInitialAccessTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcClientInitialAccessTests.cs index 4bef62d..2a8bcc3 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcClientInitialAccessTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcClientInitialAccessTests.cs @@ -76,11 +76,11 @@ public async Task A_ShouldCreateClientInitialAccess() Assert.IsNotNull(createClientInitialAccessResponse.Response); // Ensure the response is of the expected type. - Assert.IsInstanceOfType(createClientInitialAccessResponse.Response); + _ = Assert.IsInstanceOfType(createClientInitialAccessResponse.Response); // Validate the monitoring metrics for the successful token creation request. KcCommonAssertion.AssertResponseMonitoringMetrics(createClientInitialAccessResponse.MonitoringMetrics, - HttpStatusCode.OK, HttpMethod.Post); + HttpStatusCode.Created, HttpMethod.Post); // Update the test client initial access with the created token. TestClientInitialAccess = createClientInitialAccessResponse.Response; diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcClientTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcClientTests.cs index 0447fdc..a4e3407 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcClientTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcClientTests.cs @@ -51,7 +51,7 @@ public void ShouldCreateClient() /// [TestMethod] public void ShouldValidateClientConfiguration() => - _ = Assert.ThrowsException(() => new KeycloakClient(null)); + _ = Assert.ThrowsExactly(() => new KeycloakClient(null)); /// /// Tests that the base URL provided to the is properly truncated. diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcClientsTests/KcClientHappyPathTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcClientsTests/KcClientHappyPathTests.cs index e52866b..831b40e 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcClientsTests/KcClientHappyPathTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcClientsTests/KcClientHappyPathTests.cs @@ -734,7 +734,7 @@ public async Task Z_ShouldGetRegistrationAccessToken() Assert.IsNotNull(registrationAccessTokenResponse.Response); // Ensure the response is of the expected type. - Assert.IsInstanceOfType(registrationAccessTokenResponse.Response); + _ = Assert.IsInstanceOfType(registrationAccessTokenResponse.Response); // Validate the monitoring metrics for the successful registration access token retrieval request. KcCommonAssertion.AssertResponseMonitoringMetrics(registrationAccessTokenResponse.MonitoringMetrics, diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcCommonAssertion.cs b/NETCore.Keycloak.Client.Tests/Modules/KcCommonAssertion.cs index f070e5c..7166e61 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcCommonAssertion.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcCommonAssertion.cs @@ -32,13 +32,13 @@ public static void AssertResponseMonitoringMetrics( // Validate the HTTP method is not null and matches the expected method. Assert.IsNotNull(monitoringMetrics.HttpMethod); - Assert.AreEqual(monitoringMetrics.HttpMethod, expectedMethod); + Assert.AreEqual(expectedMethod, monitoringMetrics.HttpMethod); // Validate the error state matches the isError flag. - Assert.IsTrue(string.IsNullOrWhiteSpace(monitoringMetrics.Error) == !isError); + Assert.IsTrue(string.IsNullOrWhiteSpace(monitoringMetrics.Error) == !isError, monitoringMetrics.Error); // Validate the status code matches the expected status code. - Assert.AreEqual(monitoringMetrics.StatusCode, expectedStatusCode); + Assert.AreEqual(expectedStatusCode, monitoringMetrics.StatusCode); // Ensure the URL is not null. Assert.IsNotNull(monitoringMetrics.Url); diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcCommonTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcCommonTests.cs index 08d964d..074fffa 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcCommonTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcCommonTests.cs @@ -52,7 +52,7 @@ public void Init() /// [TestMethod] public async Task ShouldValidateRequiredString() => - _ = await Assert.ThrowsExceptionAsync(async () => + _ = await Assert.ThrowsExactlyAsync(async () => await _client.Auth.GetClientCredentialsTokenAsync( null, new KcClientCredentials diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcGroupTests/KcGroupsHappyPathTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcGroupTests/KcGroupsHappyPathTests.cs index b4ddcf6..ff9e7a9 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcGroupTests/KcGroupsHappyPathTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcGroupTests/KcGroupsHappyPathTests.cs @@ -187,7 +187,7 @@ public async Task D_ShouldGetAGroup() Assert.IsNotNull(getGroupResponse.Response); // Ensure the response is of the expected group type. - Assert.IsInstanceOfType(getGroupResponse.Response); + _ = Assert.IsInstanceOfType(getGroupResponse.Response); // Validate the monitoring metrics for the successful group retrieval request. KcCommonAssertion.AssertResponseMonitoringMetrics(getGroupResponse.MonitoringMetrics, HttpStatusCode.OK, diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcOrganizationTests/KcOrganizationHappyPathTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcOrganizationTests/KcOrganizationHappyPathTests.cs index b21fcc2..8002a53 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcOrganizationTests/KcOrganizationHappyPathTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcOrganizationTests/KcOrganizationHappyPathTests.cs @@ -82,7 +82,7 @@ public async Task A_ShouldCreateOrganization() // Validate the response from the organization creation operation. Assert.IsNotNull(createOrganizationResponse); - Assert.IsFalse(createOrganizationResponse.IsError); + Assert.IsFalse(createOrganizationResponse.IsError, createOrganizationResponse.ErrorMessage); // Validate the monitoring metrics for the successful organization creation request. KcCommonAssertion.AssertResponseMonitoringMetrics(createOrganizationResponse.MonitoringMetrics, @@ -206,7 +206,7 @@ public async Task D_ShouldGetOrganization() Assert.IsNotNull(getOrganizationResponse.Response); // Ensure the response is of the expected organization type. - Assert.IsInstanceOfType(getOrganizationResponse.Response); + _ = Assert.IsInstanceOfType(getOrganizationResponse.Response); // Ensure the retrieved organization name matches the expected value. Assert.AreEqual(TestOrganization.Name, getOrganizationResponse.Response.Name); diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcOrganizationTests/KcOrganizationSerializationTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcOrganizationTests/KcOrganizationSerializationTests.cs new file mode 100644 index 0000000..919ec73 --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/Modules/KcOrganizationTests/KcOrganizationSerializationTests.cs @@ -0,0 +1,49 @@ +using NETCore.Keycloak.Client.Models.Organizations; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace NETCore.Keycloak.Client.Tests.Modules.KcOrganizationTests; + +/// +/// Verifies the organization JSON contract for both supported serializers. +/// +[TestClass] +public class KcOrganizationSerializationTests +{ + /// + /// Keeps organization and nested domain field names compatible with the Keycloak REST API. + /// + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ShouldUseKeycloakFieldNames(bool useSystemTextJson) + { + var organization = new KcOrganization + { + Id = "organization-id", + Name = "Example", + Alias = "example", + Enabled = true, + Description = "Test organization", + RedirectUrl = "https://example.com", + Attributes = new Dictionary> { ["test_attr"] = ["value"] }, + Domains = [new KcOrganizationDomain { Name = "example.com", Verified = false }] + }; + + var serialized = useSystemTextJson + ? System.Text.Json.JsonSerializer.Serialize(organization) + : JsonConvert.SerializeObject(organization); + var json = JObject.Parse(serialized); + + CollectionAssert.AreEquivalent( + new[] { "id", "name", "alias", "enabled", "description", "redirectUrl", "attributes", "domains" }, + json.Properties().Select(property => property.Name).ToArray()); + Assert.AreEqual("Example", json["name"]?.Value()); + var domain = json["domains"]?.First; + Assert.IsNotNull(domain); + Assert.AreEqual("example.com", domain["name"]?.Value()); + Assert.AreEqual(false, domain["verified"]?.Value()); + Assert.IsNull(domain["Name"]); + Assert.IsNull(domain["Verified"]); + } +} diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcUserTests/KcUserHappyPathTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcUserTests/KcUserHappyPathTests.cs index 7dce58c..a3194d4 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcUserTests/KcUserHappyPathTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcUserTests/KcUserHappyPathTests.cs @@ -174,7 +174,7 @@ public async Task C_ShouldGetUsers() Assert.IsNotNull(getUserResponse.Response); // Ensure that the response is of the expected user type. - Assert.IsInstanceOfType(getUserResponse.Response); + _ = Assert.IsInstanceOfType(getUserResponse.Response); // Validate the monitoring metrics for the successful user retrieval request. KcCommonAssertion.AssertResponseMonitoringMetrics(getUserResponse.MonitoringMetrics, HttpStatusCode.OK, diff --git a/NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj b/NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj index cf0e2f3..5e5751c 100644 --- a/NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj +++ b/NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj @@ -1,8 +1,8 @@  - net8.0;net9.0;net10.0 - latest + net10.0 + latest enable disable false @@ -17,12 +17,12 @@ - - - - - - + + + + + + diff --git a/NETCore.Keycloak.Client.Tests/README.md b/NETCore.Keycloak.Client.Tests/README.md index ba7e567..2fa0d1a 100644 --- a/NETCore.Keycloak.Client.Tests/README.md +++ b/NETCore.Keycloak.Client.Tests/README.md @@ -31,9 +31,7 @@ Key features of the test infrastructure: - Docker Compose v2 3. **.NET SDK**: - - .NET 6.0 SDK - - .NET 7.0 SDK - - .NET 8.0 SDK + - .NET 10.0 SDK 4. **Build Tools**: - Cake (C# Make) @@ -59,6 +57,29 @@ make install_virtual_env ## Running Tests +### Run the complete .NET 10 test suite + +The complete suite includes integration tests that create, update, and delete Keycloak resources. +Use a dedicated test environment prepared with the Ansible/Cake setup described below. +The generated `Assets/testing_environment.json` must exist in this test project before building; +the project copies it to the test output directory. This file contains local credentials and is +intentionally excluded from Git. A missing file causes integration test classes to fail during +initialization, before their test methods run. + +From the repository root, run: + +```powershell +dotnet test NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj --framework net10.0 --configuration Release --logger trx +``` + +In Visual Studio, rebuild the test project after generating or updating the configuration, then +run the `NETCore.Keycloak.Client.Tests (net10.0)` group. Run complete test classes: the integration +scenarios with `A_` through `Z_` method names share resources created by earlier steps. + +Some existing tests intentionally report `Inconclusive` on Keycloak 26+ for transient-session +visibility and public-client management-permission behavior. These version guards are separate +from failures and should be reported as skipped tests. + The test suite uses Cake for build automation and includes several types of tests: ### Test Commands diff --git a/NETCore.Keycloak.Client.Tests/UtilsTests/KcDependencyCompatibilityTests.cs b/NETCore.Keycloak.Client.Tests/UtilsTests/KcDependencyCompatibilityTests.cs new file mode 100644 index 0000000..c62b817 --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/UtilsTests/KcDependencyCompatibilityTests.cs @@ -0,0 +1,71 @@ +using NETCore.Keycloak.Client.Models.Tokens; +using NETCore.Keycloak.Client.Tests.MockData; + +namespace NETCore.Keycloak.Client.Tests.UtilsTests; + +/// +/// Verifies password and JWT behavior across dependency upgrades. +/// +[TestClass] +public class KcDependencyCompatibilityTests +{ + /// + /// Keeps the password lengths used by user fixtures and JWT signing keys. + /// + [TestMethod] + [DataRow(16)] + [DataRow(32)] + public void ShouldGeneratePasswordsWithRequestedLength(int length) + { + var password = KcTestPasswordCreator.Create(length); + + Assert.AreEqual(length, password.Length); + Assert.IsTrue(password.All(character => character is >= '!' and <= '~')); + } + + /// + /// Keeps disabled character groups out of generated passwords. + /// + [TestMethod] + [DataRow(true, false, false, false)] + [DataRow(false, true, false, false)] + [DataRow(false, false, true, false)] + [DataRow(false, false, false, true)] + public void ShouldRespectEnabledPasswordCharacters(bool lowerCase, bool upperCase, bool digits, bool specialAscii) + { + var password = KcTestPasswordCreator.Create(16, lowerCase, upperCase, digits, specialAscii); + + Assert.AreEqual(16, password.Length); + Assert.IsTrue(password.All(character => + (lowerCase && char.IsAsciiLetterLower(character)) || + (upperCase && char.IsAsciiLetterUpper(character)) || + (digits && char.IsAsciiDigit(character)) || + (specialAscii && character is >= '!' and <= '~' && !char.IsAsciiLetterOrDigit(character)))); + } + + /// + /// Exercises signing and parsing the JWT format used by test fixtures. + /// + [TestMethod] + public void ShouldReadSessionIdFromGeneratedJwt() + { + var token = new KcIdentityProviderToken { AccessToken = KcJwtTokenMock.CreateMockJwtToken() }; + + Assert.AreEqual("mock-session-id", token.GetSessionId()); + } + + /// + /// Keeps invalid and empty tokens from producing a session ID. + /// + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("not-a-jwt")] + public void ShouldReturnNullSessionIdForInvalidTokens(string accessToken) + { + var token = new KcIdentityProviderToken { AccessToken = accessToken }; + + Assert.IsNull(token.GetSessionId()); + } +} diff --git a/NETCore.Keycloak.Client/HttpClients/Implementation/KcAuth.cs b/NETCore.Keycloak.Client/HttpClients/Implementation/KcAuth.cs index 11cb5a0..45cb43a 100644 --- a/NETCore.Keycloak.Client/HttpClients/Implementation/KcAuth.cs +++ b/NETCore.Keycloak.Client/HttpClients/Implementation/KcAuth.cs @@ -1,3 +1,4 @@ +using System.Net.Http.Headers; using Microsoft.Extensions.Logging; using NETCore.Keycloak.Client.Constants; using NETCore.Keycloak.Client.HttpClients.Abstraction; @@ -521,9 +522,6 @@ public async Task> GetRequestPartyTokenAsync // Retrieve an HttpClient instance from the factory or default shared instance. var client = CreateHttpClient(); - // Add the Authorization header with the provided access token. - _ = client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", $"Bearer {accessToken}"); - // Prepare the form data for the RPT request. var formData = new Dictionary { @@ -543,11 +541,14 @@ public async Task> GetRequestPartyTokenAsync } } - // Create the form content with the prepared data. - using var form = new FormUrlEncodedContent(formData); + // Keep the caller's bearer token on this request, without changing shared client defaults. + using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) + { + Content = new FormUrlEncodedContent(formData) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - // Send the POST request to the token endpoint with the form content. - return await client.PostAsync(new Uri(tokenEndpoint), form, cancellationToken).ConfigureAwait(false); + return await client.SendAsync(request, cancellationToken).ConfigureAwait(false); }, new KcHttpMonitoringFallbackModel { Url = tokenEndpoint, // Log the token endpoint URL for monitoring. diff --git a/NETCore.Keycloak.Client/Models/Organizations/KcOrganization.cs b/NETCore.Keycloak.Client/Models/Organizations/KcOrganization.cs index 339dd82..a78c01b 100644 --- a/NETCore.Keycloak.Client/Models/Organizations/KcOrganization.cs +++ b/NETCore.Keycloak.Client/Models/Organizations/KcOrganization.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Newtonsoft.Json; namespace NETCore.Keycloak.Client.Models.Organizations; @@ -14,6 +15,7 @@ public sealed class KcOrganization /// /// A string representing the unique identifier of the organization. /// + [JsonProperty("id")] [JsonPropertyName("id")] public string Id { get; set; } @@ -23,6 +25,7 @@ public sealed class KcOrganization /// /// A string representing the display name of the organization. /// + [JsonProperty("name")] [JsonPropertyName("name")] public string Name { get; set; } @@ -32,6 +35,7 @@ public sealed class KcOrganization /// /// A string representing an alternate identifier or alias for the organization. /// + [JsonProperty("alias")] [JsonPropertyName("alias")] public string Alias { get; set; } @@ -42,6 +46,7 @@ public sealed class KcOrganization /// A nullable boolean that is true when the organization is enabled, false when disabled, /// or null if the enabled state is not set. /// + [JsonProperty("enabled")] [JsonPropertyName("enabled")] public bool? Enabled { get; set; } @@ -51,6 +56,7 @@ public sealed class KcOrganization /// /// A string containing a human-readable description for the organization. /// + [JsonProperty("description")] [JsonPropertyName("description")] public string Description { get; set; } @@ -60,6 +66,7 @@ public sealed class KcOrganization /// /// A string representing the redirect URL associated with the organization (for example, after login or registration flows). /// + [JsonProperty("redirectUrl")] [JsonPropertyName("redirectUrl")] public string RedirectUrl { get; set; } @@ -69,6 +76,7 @@ public sealed class KcOrganization /// /// A dictionary where the key is the attribute name and the value is a list of values for that attribute (Keycloak style). /// + [JsonProperty("attributes")] [JsonPropertyName("attributes")] public Dictionary> Attributes { get; set; } @@ -78,6 +86,7 @@ public sealed class KcOrganization /// /// A collection of representing domains associated with the organization. /// + [JsonProperty("domains")] [JsonPropertyName("domains")] public ICollection Domains { get; set; } = []; } diff --git a/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationDomain.cs b/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationDomain.cs index f4424e6..69cab86 100644 --- a/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationDomain.cs +++ b/NETCore.Keycloak.Client/Models/Organizations/KcOrganizationDomain.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Newtonsoft.Json; namespace NETCore.Keycloak.Client.Models.Organizations; @@ -14,6 +15,7 @@ public sealed class KcOrganizationDomain /// /// A string representing the domain name (for example, "example.com"). /// + [JsonProperty("name")] [JsonPropertyName("name")] public string Name { get; set; } @@ -24,6 +26,7 @@ public sealed class KcOrganizationDomain /// A nullable boolean indicating whether the domain has been verified by Keycloak. /// True if verified; false if not; null if the verification state is unknown. /// + [JsonProperty("verified")] [JsonPropertyName("verified")] public bool? Verified { get; set; } } diff --git a/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj b/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj index b96d464..0f5b16f 100644 --- a/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj +++ b/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj @@ -14,7 +14,7 @@ git README.md https://github.com/Black-Cockpit/NETCore.Keycloak - net8.0;net9.0;net10.0 + net10.0 latest enable disable @@ -28,39 +28,15 @@ - - + - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/NETCore.Keycloak.Client/README.md b/NETCore.Keycloak.Client/README.md index ea4970e..4757628 100644 --- a/NETCore.Keycloak.Client/README.md +++ b/NETCore.Keycloak.Client/README.md @@ -8,7 +8,7 @@ | Category | Supported Versions | | ------------ | ----------------------------------------------------------------------- | -| .NET | 6.0, 7.0, 8.0 | +| .NET | 10.0 | | Dependencies | ASP.NET Core, Microsoft.Extensions.DependencyInjection, Newtonsoft.Json | ## ✅ Version Compatibility @@ -46,7 +46,7 @@ Install-Package Keycloak.NETCore.Client ### 📋 Prerequisites -- ✳️ .NET Core SDK (version 6.0 or later) +- ✳️ .NET 10.0 SDK - 🖥️ A running Keycloak instance - 🔑 Client credentials and realm configuration diff --git a/README.md b/README.md index 21314e1..4e07f76 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ | Category | Supported Versions | | ------------ | ----------------------------------------------------------------------- | -| .NET | 8.0, 9.0, 10.0 | +| .NET | 10.0 | | Dependencies | ASP.NET Core, Microsoft.Extensions.DependencyInjection, Newtonsoft.Json | ## ✅ Version Compatibility @@ -95,7 +95,7 @@ Install-Package Keycloak.NETCore.Client ### 📋 Prerequisites -- ✳️ .NET SDK (version 8.0 or later) +- ✳️ .NET 10.0 SDK - 🖥️ A running Keycloak instance - 🔑 Client credentials and realm configuration From 207db87e45d549e9285539cd2433d5bafbb15017 Mon Sep 17 00:00:00 2001 From: jeff Date: Tue, 3 Nov 2026 20:32:05 +0800 Subject: [PATCH 2/7] =?UTF-8?q?Verify=20Keycloak=2026.8.1=20compatibility?= =?UTF-8?q?=20/=20=E9=AA=8C=E8=AF=81=20Keycloak=2026.8.1=20=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify the existing .NET 10 client against Keycloak 26.8.1. Fix repeated UMA permissions, composite-role removal, resource serialization, and admin-token expiry, refresh fallback and concurrent renewal. Add optional request-scoped Basic client authentication while preserving Post. Add isolated security regressions, independent units, FGAP v1/v2 CI and a complete inventory/coverage gate for 178 public methods across 13 modules. 验证现有 .NET 10 客户端与 Keycloak 26.8.1 的兼容性。 修复多权限 UMA 请求、组合角色移除、资源序列化及管理员令牌续期。 新增可选 Basic 认证、隔离安全回归、独立单测、双配置 CI 和完整接口清单。 Validation / 验证: - Full local FGAP v1 suite: 434 passed, zero failures/skips. - Independent units without integration credentials: 54 passed (included above). - FGAP v2 targeted regressions: 31 passed, zero failures/skips. - Coverage: 99.65% lines, 83.72% branches; 178 declared methods executed. - NuGet direct/transitive vulnerability scan: no known vulnerable packages. - Linux/PostgreSQL GitHub CI remains pending the manually created PR. --- .github/workflows/keycloak_2681.yml | 86 ++ CONTRIBUTING.md | 2 +- .../Abstraction/KcTestingModule.cs | 7 + .../Infrastructure/StubHttpTransport.cs | 18 + NETCore.Keycloak.Client.Tests/Makefile | 6 +- .../Models/KcTestEnvironment.cs | 6 +- .../KcAuthRequestPartyTokenUnitTests.cs | 28 +- .../KcClientAuthenticationTests.cs | 104 ++ .../KcAuthTests/KcHttpBoundaryUnitTests.cs | 206 ++++ .../KcAuthenticationBoundaryUnitTests.cs | 116 +++ .../KcBearerAuthorizationHandlerTests.cs | 12 - .../KcBearerBoundaryUnitTests.cs | 115 ++ .../KcDynamicPolicyUnitTests.cs | 55 + .../KcTokenRenewalUnitTests.cs | 244 +++++ .../KcClientsTests/KcClientHappyPathTests.cs | 23 +- .../Kc2681CompatibilityTests.cs | 983 ++++++++++++++++++ .../KcSerializationContractUnitTests.cs | 269 +++++ .../Modules/KcUserTests/KcUserTests.cs | 12 - NETCore.Keycloak.Client.Tests/README.md | 433 +++++++- .../environment/generate_test_environment.yml | 3 +- .../tasks/keycloak/clients/app_client.yml | 4 +- .../ansible/templates/keycloak.env.j2 | 2 +- .../ansible/utils/extra/pre_flight.yml | 8 +- .../ansible/vars/main.yml | 5 + .../verify_test_results.py | 74 ++ .../Handlers/KcBearerAuthorizationHandler.cs | 6 +- .../Handlers/KcRealmAdminTokenHandler.cs | 103 +- .../KcProtectedResourcePolicyProvider.cs | 7 +- .../HttpClients/Abstraction/IKcRoles.cs | 3 +- .../HttpClients/Implementation/KcAuth.cs | 74 +- .../HttpClients/Implementation/KcRoles.cs | 21 +- .../Auth/KcClientAuthenticationMethod.cs | 17 + .../Models/Auth/KcClientCredentials.cs | 15 +- .../Models/Clients/KcClientResource.cs | 6 +- .../NETCore.Keycloak.Client.csproj | 4 +- .../Serialization/KcJsonEnumConverter.cs | 4 +- README.md | 5 + 37 files changed, 2964 insertions(+), 122 deletions(-) create mode 100644 .github/workflows/keycloak_2681.yml create mode 100644 NETCore.Keycloak.Client.Tests/Infrastructure/StubHttpTransport.cs create mode 100644 NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcClientAuthenticationTests.cs create mode 100644 NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcHttpBoundaryUnitTests.cs create mode 100644 NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcAuthenticationBoundaryUnitTests.cs create mode 100644 NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcBearerBoundaryUnitTests.cs create mode 100644 NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcDynamicPolicyUnitTests.cs create mode 100644 NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcTokenRenewalUnitTests.cs create mode 100644 NETCore.Keycloak.Client.Tests/Modules/KcCompatibilityTests/Kc2681CompatibilityTests.cs create mode 100644 NETCore.Keycloak.Client.Tests/Modules/KcCompatibilityTests/KcSerializationContractUnitTests.cs create mode 100644 NETCore.Keycloak.Client.Tests/verify_test_results.py create mode 100644 NETCore.Keycloak.Client/Models/Auth/KcClientAuthenticationMethod.cs diff --git a/.github/workflows/keycloak_2681.yml b/.github/workflows/keycloak_2681.yml new file mode 100644 index 0000000..14fe1b1 --- /dev/null +++ b/.github/workflows/keycloak_2681.yml @@ -0,0 +1,86 @@ +name: Keycloak 26.8.1 compatibility + +on: + pull_request: + branches: [master] + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +jobs: + unit: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Run independent coverage unit tests + run: | + dotnet test NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj \ + --configuration Release --filter 'TestCategory=CoverageUnit' --collect 'XPlat Code Coverage' \ + --logger 'trx;LogFileName=unit.trx' --results-directory NETCore.Keycloak.Client.Tests/obj/unit-verification + python3 NETCore.Keycloak.Client.Tests/verify_test_results.py NETCore.Keycloak.Client.Tests/obj/unit-verification + - name: Upload unit test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: independent-unit-tests + path: | + NETCore.Keycloak.Client.Tests/obj/unit-verification/**/*.trx + NETCore.Keycloak.Client.Tests/obj/unit-verification/**/coverage.cobertura.xml + compatibility: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + fgap: [v1, v2] + defaults: + run: + working-directory: NETCore.Keycloak.Client.Tests + shell: bash + env: + KC_VERSION: 26.8.1 + KC_FGAP_VERSION: ${{ matrix.fgap }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Prepare isolated Keycloak 26.8.1 environment + run: make prepare_keycloak_26_environment + - name: Run complete suite or FGAP v2 regressions + run: | + filter=() + if [[ "$KC_FGAP_VERSION" == "v2" ]]; then + # Legacy /management/permissions APIs require FGAP v1 and are covered by the full v1 job. + filter=(--filter 'TestCategory=Keycloak2681&TestCategory!=LegacyAdminPermissions') + fi + dotnet test NETCore.Keycloak.Client.Tests.csproj --framework net10.0 --configuration Release \ + "${filter[@]}" --logger 'trx;LogFileName=keycloak-26.8.1.trx' \ + --results-directory "obj/keycloak-26.8.1/$KC_FGAP_VERSION" --collect 'XPlat Code Coverage' + coverage_check=() + if [[ "$KC_FGAP_VERSION" == "v1" ]]; then + coverage_check=(--require-http-method-coverage) + fi + python verify_test_results.py "obj/keycloak-26.8.1/$KC_FGAP_VERSION" "${coverage_check[@]}" + - name: Upload test and coverage reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: keycloak-26.8.1-fgap-${{ matrix.fgap }} + path: | + NETCore.Keycloak.Client.Tests/obj/keycloak-26.8.1/**/*.trx + NETCore.Keycloak.Client.Tests/obj/keycloak-26.8.1/**/coverage.cobertura.xml + if-no-files-found: warn + - name: Stop the test environment + if: always() + run: make stop diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c59b5bd..4e7f1c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ Examples: ### Pull Request Process -1. Fork the repo and create your branch from `main` following the branch naming convention +1. Fork the repo and create your branch from `master` following the branch naming convention 2. Ensure your code follows the existing code style and formatting rules defined in `.editorconfig` 3. Update the documentation if needed 4. Ensure all tests pass diff --git a/NETCore.Keycloak.Client.Tests/Abstraction/KcTestingModule.cs b/NETCore.Keycloak.Client.Tests/Abstraction/KcTestingModule.cs index f792b8b..f3751e7 100644 --- a/NETCore.Keycloak.Client.Tests/Abstraction/KcTestingModule.cs +++ b/NETCore.Keycloak.Client.Tests/Abstraction/KcTestingModule.cs @@ -564,6 +564,13 @@ protected void LoadConfiguration() TestEnvironment = JsonConvert.DeserializeObject(sr.ReadToEnd()); Assert.IsNotNull(TestEnvironment, "The test environment configuration must not be null."); + + // Allows the same isolated regressions to run against separate FGAP v1 and v2 servers. + var baseUrl = Environment.GetEnvironmentVariable("KC_TEST_BASE_URL"); + if ( !string.IsNullOrWhiteSpace(baseUrl) ) + { + TestEnvironment.BaseUrl = baseUrl; + } } /// diff --git a/NETCore.Keycloak.Client.Tests/Infrastructure/StubHttpTransport.cs b/NETCore.Keycloak.Client.Tests/Infrastructure/StubHttpTransport.cs new file mode 100644 index 0000000..0cb6758 --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/Infrastructure/StubHttpTransport.cs @@ -0,0 +1,18 @@ +using System.Net; +using System.Text; + +namespace NETCore.Keycloak.Client.Tests.Infrastructure; + +internal sealed class StubHttpTransport(Func> respond) + : HttpMessageHandler, IHttpClientFactory +{ + public HttpClient CreateClient(string name) => new(this, disposeHandler: false); + + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) => respond(request); + + public static HttpResponseMessage Json(string json, HttpStatusCode status = HttpStatusCode.OK) => new(status) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; +} diff --git a/NETCore.Keycloak.Client.Tests/Makefile b/NETCore.Keycloak.Client.Tests/Makefile index d6f4deb..fe615ad 100644 --- a/NETCore.Keycloak.Client.Tests/Makefile +++ b/NETCore.Keycloak.Client.Tests/Makefile @@ -19,6 +19,10 @@ CURRENT_MK := $(lastword $(MAKEFILE_LIST)) VIRTUAL_ENV_DIR := keycloak.venv export VIRTUAL_ENV_DIR +# Pin the current compatibility target; select v2 for the dedicated security regression profile. +KC_VERSION ?= 26.8.1 +KC_FGAP_VERSION ?= v1 + # Directory context for the project CONF_DIR_CONTEXT := "." @@ -82,7 +86,7 @@ prepare_keycloak_25_environment: check_virtual_env stop @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-playbook ansible/provision_keycloak.yml -e "stack_state=present" -e "kc_version=25.0.6" prepare_keycloak_26_environment: check_virtual_env stop - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-playbook ansible/provision_keycloak.yml -e "stack_state=present" -e "kc_version=26.5.6" + @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-playbook ansible/provision_keycloak.yml -e "stack_state=present" -e "kc_version=$(KC_VERSION)" -e "kc_fgap_version=$(KC_FGAP_VERSION)" # ----------------------------------- # Target: stop diff --git a/NETCore.Keycloak.Client.Tests/Models/KcTestEnvironment.cs b/NETCore.Keycloak.Client.Tests/Models/KcTestEnvironment.cs index f68f3df..3e946c7 100644 --- a/NETCore.Keycloak.Client.Tests/Models/KcTestEnvironment.cs +++ b/NETCore.Keycloak.Client.Tests/Models/KcTestEnvironment.cs @@ -45,9 +45,13 @@ public class KcTestEnvironment /// Gets or sets the administrative credentials for the master realm. /// Includes login details required for administrative operations. /// - [JsonProperty("user")] + [JsonProperty("admin")] public KcUserLogin Admin { get; set; } + // Accept configurations generated before the template used the admin key. + [JsonProperty("user")] + private KcUserLogin LegacyAdmin { set => Admin ??= value; } + /// /// Gets or sets the configuration for the testing realm in Keycloak. /// This includes details such as the realm name, clients, and test users. diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthRequestPartyTokenUnitTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthRequestPartyTokenUnitTests.cs index d7ccb0d..9e5ce08 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthRequestPartyTokenUnitTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcAuthRequestPartyTokenUnitTests.cs @@ -11,6 +11,26 @@ namespace NETCore.Keycloak.Client.Tests.Modules.KcAuthTests; [TestClass] public class KcAuthRequestPartyTokenUnitTests { + /// + /// Sends every requested resource/scope pair as a separate form field. + /// + [TestMethod] + public async Task ShouldSendMultiplePermissionsWithoutMergingOrLosingScopes() + { + using var handler = new RecordingHandler(); + using var httpClient = new HttpClient(handler); + var factory = new Mock(); + _ = factory.Setup(value => value.CreateClient(It.IsAny())).Returns(httpClient); + var client = new KeycloakClient("https://keycloak.test", null, factory.Object); + + var response = await client.Auth.GetRequestPartyTokenAsync("test-realm", "user-token", "auth_client", + ["first#read", "second#read", "first#write"]).ConfigureAwait(false); + + Assert.IsFalse(response.IsError, response.ErrorMessage); + Assert.AreEqual("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Auma-ticket&audience=auth_client" + + "&permission=first%23read&permission=second%23read&permission=first%23write", handler.Bodies.Single()); + } + /// /// Uses the current caller's token without changing shared client defaults. /// @@ -39,16 +59,18 @@ public async Task ShouldKeepBearerTokensScopedToEachRequest() private sealed class RecordingHandler : HttpMessageHandler { public List AuthorizationHeaders { get; } = []; + public List Bodies { get; } = []; - protected override Task SendAsync(HttpRequestMessage request, + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { AuthorizationHeaders.Add(request.Headers.GetValues("Authorization").ToArray()); - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + Bodies.Add(await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"access_token\":\"rpt-token\",\"token_type\":\"Bearer\"}"), RequestMessage = request - }); + }; } } } diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcClientAuthenticationTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcClientAuthenticationTests.cs new file mode 100644 index 0000000..8827ca6 --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcClientAuthenticationTests.cs @@ -0,0 +1,104 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Moq; +using NETCore.Keycloak.Client.Exceptions; +using NETCore.Keycloak.Client.HttpClients.Implementation; +using NETCore.Keycloak.Client.Models.Auth; + +namespace NETCore.Keycloak.Client.Tests.Modules.KcAuthTests; + +/// +/// Verifies OAuth client authentication on every credential-bearing endpoint. +/// +[TestClass] +public class KcClientAuthenticationTests +{ + /// + /// Keeps credentials scoped to requests and uses the selected authentication method for every grant. + /// + /// Whether credentials belong in the Basic header. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task ShouldAuthenticateTokenRefreshAndRevocationRequests(bool basic) + { + using var handler = new RecordingHandler(); + using var httpClient = new HttpClient(handler); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "factory-default"); + var factory = new Mock(); + _ = factory.Setup(value => value.CreateClient(It.IsAny())).Returns(httpClient); + var client = new KeycloakClient("https://keycloak.test", null, factory.Object); + var credentials = new KcClientCredentials + { + ClientId = "client :+&", + Secret = "sëcret :+&", + AuthenticationMethod = basic + ? KcClientAuthenticationMethod.ClientSecretBasic + : KcClientAuthenticationMethod.ClientSecretPost + }; + var login = new KcUserLogin { Username = "test-user", Password = "test-password" }; + + Assert.IsFalse((await client.Auth.GetClientCredentialsTokenAsync("realm", credentials) + .ConfigureAwait(false)).IsError); + Assert.IsFalse((await client.Auth.GetResourceOwnerPasswordTokenAsync("realm", credentials, login) + .ConfigureAwait(false)).IsError); + Assert.IsFalse((await client.Auth.RefreshAccessTokenAsync("realm", credentials, "refresh-token") + .ConfigureAwait(false)).IsError); + Assert.IsFalse((await client.Auth.RevokeAccessTokenAsync("realm", credentials, "access-token") + .ConfigureAwait(false)).IsError); + Assert.IsFalse((await client.Auth.RevokeRefreshTokenAsync("realm", credentials, "refresh-token") + .ConfigureAwait(false)).IsError); + var validation = await client.Auth.ValidatePasswordAsync("realm", credentials, login).ConfigureAwait(false); + Assert.IsTrue(validation.Response); + Assert.AreEqual(8, handler.Requests.Count); + + foreach ( var (authorization, body) in handler.Requests ) + { + if ( basic ) + { + Assert.AreEqual("Basic " + Convert.ToBase64String( + Encoding.UTF8.GetBytes("client+%3A%2B%26:s%C3%ABcret+%3A%2B%26")), authorization); + Assert.IsFalse(body.Contains("client_id=", StringComparison.Ordinal)); + Assert.IsFalse(body.Contains("client_secret=", StringComparison.Ordinal)); + } + else + { + StringAssert.Contains(body, "client_id=client+%3A%2B%26", StringComparison.Ordinal); + StringAssert.Contains(body, "client_secret=s%C3%ABcret+%3A%2B%26", StringComparison.Ordinal); + } + } + Assert.AreEqual("Bearer factory-default", httpClient.DefaultRequestHeaders.Authorization?.ToString()); + } + + /// + /// Rejects unknown authentication methods before credentials can be sent using an unintended method. + /// + [TestMethod] + public void ShouldRejectUnknownAuthenticationMethod() + { + var credentials = new KcClientCredentials + { + ClientId = "client", + AuthenticationMethod = ( KcClientAuthenticationMethod ) 99 + }; + _ = Assert.ThrowsExactly(credentials.Validate); + } + + private sealed class RecordingHandler : HttpMessageHandler + { + public List<(string Authorization, string Body)> Requests { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add((request.Headers.Authorization?.ToString(), + await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false))); + return new HttpResponseMessage(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent("{\"access_token\":\"access\",\"refresh_token\":\"refresh\"}") + }; + } + } +} diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcHttpBoundaryUnitTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcHttpBoundaryUnitTests.cs new file mode 100644 index 0000000..2debf75 --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthTests/KcHttpBoundaryUnitTests.cs @@ -0,0 +1,206 @@ +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using NETCore.Keycloak.Client.Exceptions; +using NETCore.Keycloak.Client.HttpClients.Implementation; +using NETCore.Keycloak.Client.Models; +using NETCore.Keycloak.Client.Models.Auth; +using NETCore.Keycloak.Client.Tests.Infrastructure; + +namespace NETCore.Keycloak.Client.Tests.Modules.KcAuthTests; + +/// +/// Exercises malformed responses, optional OAuth parameters and no-op batch semantics without network I/O. +/// +[TestClass] +[TestCategory("CoverageUnit")] +public sealed class KcHttpBoundaryUnitTests +{ + /// + /// Unreadable or malformed successful response bodies become structured failures for revocation/RPT operations. + /// + /// The operation returning malformed JSON. + /// Whether an optional logger is supplied. + [TestMethod] + [DataRow("access", false)] + [DataRow("access", true)] + [DataRow("refresh", false)] + [DataRow("refresh", true)] + [DataRow("rpt", false)] + [DataRow("rpt", true)] + public async Task ShouldCaptureMalformedRevocationAndRptResponses(string operation, bool logging) + { + using var transport = new StubHttpTransport(_ => + { + var response = StubHttpTransport.Json(operation == "rpt" ? "not-json" : "body"); + if ( operation != "rpt" ) + { + response.Content.Headers.ContentType!.CharSet = "unknown-response-charset"; + } + return Task.FromResult(response); + }); + var client = new KeycloakClient("https://keycloak.test", logging ? NullLogger.Instance : null, transport); + var credentials = new KcClientCredentials { ClientId = "client" }; + switch ( operation ) + { + case "access": + AssertMalformed(await client.Auth.RevokeAccessTokenAsync("test", credentials, "access") + .ConfigureAwait(false)); + break; + case "refresh": + AssertMalformed(await client.Auth.RevokeRefreshTokenAsync("test", credentials, "refresh") + .ConfigureAwait(false)); + break; + default: + AssertMalformed(await client.Auth.GetRequestPartyTokenAsync("test", "access", "api") + .ConfigureAwait(false)); + break; + } + } + + /// + /// Sends optional scopes/resources intact and exercises the optional scope variants on evaluation endpoints. + /// + [TestMethod] + public async Task ShouldForwardOptionalScopeAndResourceParameters() + { + var requests = new List(); + string form = null; + using var transport = new StubHttpTransport(async request => + { + requests.Add(request.RequestUri!); + if ( request.Method == HttpMethod.Post ) + { + form = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + } + return StubHttpTransport.Json(request.RequestUri!.AbsolutePath + .EndsWith("/not-granted", StringComparison.Ordinal) + ? "[]" : "{\"sub\":\"alice\",\"access_token\":\"access\"}"); + }); + var client = new KeycloakClient("https://keycloak.test", null, transport); + var token = await client.Auth.GetResourceOwnerPasswordTokenAsync("test", + new KcClientCredentials { ClientId = "client" }, new KcUserLogin { Username = "alice", Password = "test" }, + scope: "openid profile", resource: "https://api.test/orders").ConfigureAwait(false); + Assert.IsFalse(token.IsError, token.ErrorMessage); + StringAssert.Contains(form, "scope=openid+profile", StringComparison.Ordinal); + StringAssert.Contains(form, "resource=https%3A%2F%2Fapi.test%2Forders", StringComparison.Ordinal); + Assert.IsFalse((await client.Clients.GenerateExampleAccessTokenAsync("test", "admin", "client", "alice", + "openid profile").ConfigureAwait(false)).IsError); + Assert.IsFalse((await client.Clients.GenerateExampleIdTokenAsync("test", "admin", "client", "alice", + "openid profile").ConfigureAwait(false)).IsError); + Assert.IsFalse((await client.Clients.GenerateExampleUserInfoAsync("test", "admin", "client", "alice", + "openid profile").ConfigureAwait(false)).IsError); + Assert.IsFalse((await client.Clients.GetUnScopedProtocolMappersInContainerAsync("test", "admin", "client", + "container", "openid profile").ConfigureAwait(false)).IsError); + Assert.HasCount(5, requests); + foreach ( var request in requests.Skip(1) ) + { + StringAssert.Contains(Uri.UnescapeDataString(request.Query), "scope=openid profile", + StringComparison.Ordinal); + } + } + + /// + /// Forwards non-not-found failures from role existence checks instead of reporting a nonexistent role. + /// + [TestMethod] + public async Task ShouldPropagateRoleLookupFailures() + { + using var transport = new StubHttpTransport(_ => Task.FromResult( + StubHttpTransport.Json("{\"error\":\"service_unavailable\"}", HttpStatusCode.ServiceUnavailable))); + var client = new KeycloakClient("https://keycloak.test", null, transport); + var realm = await client.Roles.IsRolesExistsAsync("test", "admin", "reader").ConfigureAwait(false); + var scoped = await client.Roles.IsClientRoleExistsAsync("test", "admin", "client", "reader") + .ConfigureAwait(false); + Assert.IsTrue(realm.IsError); + Assert.IsTrue(scoped.IsError); + StringAssert.Contains(realm.ErrorMessage, "service_unavailable", StringComparison.Ordinal); + Assert.AreEqual(realm.ErrorMessage, scoped.ErrorMessage); + } + + /// + /// A truncated HTTP response retains its underlying exception in revocation results. + /// + /// Whether the operation revokes a refresh token. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task ShouldPreserveRevocationTransportFailure(bool refresh) + { + using var transport = new StubHttpTransport(_ => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) { Content = new TruncatedContent() })); + var client = new KeycloakClient("https://keycloak.test", null, transport); + var credentials = new KcClientCredentials { ClientId = "client" }; + var response = refresh + ? await client.Auth.RevokeRefreshTokenAsync("test", credentials, "refresh").ConfigureAwait(false) + : await client.Auth.RevokeAccessTokenAsync("test", credentials, "access").ConfigureAwait(false); + Assert.IsTrue(response.IsError); + Assert.IsNotNull(response.Exception); + Assert.IsFalse(string.IsNullOrWhiteSpace(response.ErrorMessage)); + Assert.AreSame(response.Exception, response.MonitoringMetrics.RequestException); + } + + /// + /// Empty role batches must not trigger HTTP writes or destructive server changes. + /// + [TestMethod] + public async Task ShouldAvoidRequestsForEmptyRoleBatches() + { + var calls = 0; + using var transport = new StubHttpTransport(_ => + { + calls++; + return Task.FromResult(StubHttpTransport.Json("{}")); + }); + var client = new KeycloakClient("https://keycloak.test", null, transport); + var operations = new[] + { + client.Roles.AddCompositeAsync("test", "admin", "role", []), + client.Roles.AddCompositeByIdAsync("test", "admin", "role", []), + client.Roles.DeleteCompositeAsync("test", "admin", "role", []), + client.Roles.DeleteCompositeByIdAsync("test", "admin", "role", []), + client.Roles.AddClientRoleToCompositeAsync("test", "admin", "client", "role", []), + client.ScopeMappings.AddClientRolesToScopeAsync("test", "admin", "scope", "client", []), + client.ScopeMappings.RemoveClientRolesFromScopeAsync("test", "admin", "scope", "client", []), + client.ScopeMappings.AddRolesToScopeAsync("test", "admin", "scope", []), + client.ScopeMappings.RemoveRolesFromScopeAsync("test", "admin", "scope", []), + client.ScopeMappings.AddClientRolesToClientScopeAsync("test", "admin", "client", "container", []), + client.ScopeMappings.RemoveClientRolesFromClientScopeAsync("test", "admin", "client", "container", []), + client.ScopeMappings.AddRolesToScopeClientAsync("test", "admin", "client", []), + client.ScopeMappings.RemoveRolesFromClientScopeAsync("test", "admin", "client", []), + client.ClientRoleMappings.MapClientRolesToGroupAsync("test", "admin", "group", "client", []), + client.ClientRoleMappings.DeleteGroupClientRoleMappingsAsync("test", "admin", "group", "client", []), + client.ClientRoleMappings.MapClientRolesToUserAsync("test", "admin", "alice", "client", []), + client.ClientRoleMappings.DeleteUserClientRoleMappingsAsync("test", "admin", "alice", "client", []) + }; + foreach ( var result in await Task.WhenAll(operations).ConfigureAwait(false) ) + { + Assert.IsFalse(result.IsError); + Assert.IsNull(result.Response); + } + Assert.AreEqual(0, calls); + _ = Assert.ThrowsExactly(() => + _ = client.ClientRoleMappings.DeleteGroupClientRoleMappingsAsync("test", "admin", "group", "client", null)); + _ = Assert.ThrowsExactly(() => _ = new KeycloakClient(" ")); + } + + private static void AssertMalformed(KcResponse response) + { + Assert.IsTrue(response.IsError); + Assert.IsNotNull(response.Exception); + Assert.IsFalse(string.IsNullOrWhiteSpace(response.ErrorMessage)); + Assert.AreEqual(HttpMethod.Post, response.MonitoringMetrics.HttpMethod); + Assert.AreSame(response.Exception, response.MonitoringMetrics.RequestException); + } + + private sealed class TruncatedContent : HttpContent + { + protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) => + Task.FromException(new IOException("The response body ended unexpectedly.")); + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + } +} diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcAuthenticationBoundaryUnitTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcAuthenticationBoundaryUnitTests.cs new file mode 100644 index 0000000..2567f3d --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthentication/KcAuthenticationBoundaryUnitTests.cs @@ -0,0 +1,116 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using NETCore.Keycloak.Client.Authentication; +using NETCore.Keycloak.Client.Authentication.Claims; +using NETCore.Keycloak.Client.Models.KcEnum; + +namespace NETCore.Keycloak.Client.Tests.Modules.KcAuthentication; + +/// +/// Resolves the actual named JWT options and checks token validation and missing-role boundaries. +/// +[TestClass] +[TestCategory("CoverageUnit")] +public sealed class KcAuthenticationBoundaryUnitTests +{ + /// + /// Executes the registered options delegate and preserves the caller's configuration overrides. + /// + /// Whether metadata must use HTTPS. + /// Whether a caller overrides SaveToken. + [TestMethod] + [DataRow(true, false)] + [DataRow(false, true)] + public void ShouldApplyNamedJwtOptions(bool ssl, bool customize) + { + var services = new ServiceCollection().AddLogging(); + var invoked = false; + _ = services.AddKeycloakAuthentication("keycloak-unit", config => + { + config.Url = "https://keycloak.test"; + config.Issuer = "https://keycloak.test/"; + config.Realm = "test"; + config.Resource = "api"; + config.RequireSsl = ssl; + config.TokenClockSkew = TimeSpan.Zero; + config.RoleClaimType = "roles"; + config.NameClaimType = "username"; + config.ValidAudiences = ["api"]; + }, customize ? options => { invoked = true; options.SaveToken = true; } + : null); + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Get("keycloak-unit"); + Assert.AreEqual("https://keycloak.test/realms/test", options.Authority); + Assert.AreEqual("api", options.Audience); + Assert.AreEqual(ssl, options.RequireHttpsMetadata); + Assert.AreEqual(customize, options.SaveToken); + Assert.AreEqual(customize, invoked); + var validation = options.TokenValidationParameters; + Assert.IsTrue(validation.ValidateIssuer); + Assert.IsTrue(validation.ValidateAudience); + Assert.IsTrue(validation.ValidateLifetime); + Assert.IsTrue(validation.RequireExpirationTime); + Assert.AreEqual(TimeSpan.Zero, validation.ClockSkew); + Assert.AreEqual("username", validation.NameClaimType); + Assert.AreEqual("roles", validation.RoleClaimType); + Assert.AreEqual("https://keycloak.test/realms/test", validation.ValidIssuer); + + // Exercise the configured issuer/audience/lifetime checks using a local signing key, without discovery I/O. + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-only-signing-key-at-least-32-bytes-long")); + validation.IssuerSigningKey = key; + var handler = new JwtSecurityTokenHandler(); + string Sign(string issuer, string audience, bool expired = false) => handler.WriteToken(new JwtSecurityToken( + issuer, audience, [new Claim("sub", "alice")], DateTime.UtcNow.AddMinutes(-10), + expired ? DateTime.UtcNow.AddMinutes(-1) : DateTime.UtcNow.AddMinutes(5), + new SigningCredentials(key, SecurityAlgorithms.HmacSha256))); + _ = handler.ValidateToken(Sign(validation.ValidIssuer, "api"), validation, out _); + _ = Assert.ThrowsExactly(() => + handler.ValidateToken(Sign("https://other.test/realms/test", "api"), validation, out _)); + _ = Assert.ThrowsExactly(() => + handler.ValidateToken(Sign(validation.ValidIssuer, "other-api"), validation, out _)); + _ = Assert.ThrowsExactly(() => + handler.ValidateToken(Sign(validation.ValidIssuer, "api", expired: true), validation, out _)); + } + + /// + /// Missing or unrelated claims must not create roles or change the original identity. + /// + /// The configured Keycloak role source. + /// The input claim type, if present. + /// The input claim value. + [TestMethod] + [DataRow(KcRolesClaimSource.ResourceAccess, null, null)] + [DataRow(KcRolesClaimSource.ResourceAccess, "resource_access", "{\"other-api\":{\"roles\":[\"admin\"]}}")] + [DataRow(KcRolesClaimSource.Realm, null, null)] + [DataRow(KcRolesClaimSource.Realm, "realm_access", "{}")] + public async Task ShouldNotGrantRolesFromMissingOrUnrelatedClaims(KcRolesClaimSource source, + string claim, string json) + { + var identity = new ClaimsIdentity("test"); + if ( claim != null ) + { + identity.AddClaim(new Claim(claim, json)); + } + var principal = new ClaimsPrincipal(identity); + var transformed = await new KcRolesClaimsTransformer("roles", source, "api").TransformAsync(principal) + .ConfigureAwait(false); + Assert.IsFalse(transformed.HasClaim(value => value.Type == "roles")); + Assert.IsFalse(principal.HasClaim(value => value.Type == "roles")); + } + + /// + /// A principal without an identity remains without roles. + /// + [TestMethod] + public async Task ShouldLeavePrincipalWithoutIdentityUnchanged() + { + var transformed = await new KcRolesClaimsTransformer("roles", KcRolesClaimSource.Realm, "api") + .TransformAsync(new ClaimsPrincipal()).ConfigureAwait(false); + Assert.IsNull(transformed.Identity); + } +} diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcBearerAuthorizationHandlerTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcBearerAuthorizationHandlerTests.cs index a06d7ca..717159e 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcBearerAuthorizationHandlerTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcBearerAuthorizationHandlerTests.cs @@ -487,12 +487,6 @@ await _handler.TestHandleRequirementAsync(context, requirement).ConfigureAwait(f [TestMethod] public async Task H_ShouldDenyAccessForUnAuthorizedUser() { - // Skip on KC 26+ — session validation fails because password grant creates transient sessions - if ( GetKcMajorVersion() >= 26 ) - { - Assert.Inconclusive("Skipped on Keycloak 26+ — transient sessions are not visible via admin API."); - } - // Retrieve an access token using resource owner password credentials for an unauthorized user var tokenResponse = await KeycloakRestClient.Auth.GetResourceOwnerPasswordTokenAsync( TestEnvironment.TestingRealm.Name, @@ -534,12 +528,6 @@ public async Task H_ShouldDenyAccessForUnAuthorizedUser() [TestMethod] public async Task I_ShouldAllowAccessForAuthorizedUser() { - // Skip on KC 26+ — session validation fails because password grant creates transient sessions - if ( GetKcMajorVersion() >= 26 ) - { - Assert.Inconclusive("Skipped on Keycloak 26+ — transient sessions are not visible via admin API."); - } - // Retrieve an access token using resource owner password credentials for an authorized user var tokenResponse = await KeycloakRestClient.Auth.GetResourceOwnerPasswordTokenAsync( TestEnvironment.TestingRealm.Name, diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcBearerBoundaryUnitTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcBearerBoundaryUnitTests.cs new file mode 100644 index 0000000..eccf8b1 --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcBearerBoundaryUnitTests.cs @@ -0,0 +1,115 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NETCore.Keycloak.Client.Authorization.Handlers; +using NETCore.Keycloak.Client.Authorization.Requirements; +using NETCore.Keycloak.Client.Authorization.Store; +using NETCore.Keycloak.Client.Exceptions; +using NETCore.Keycloak.Client.Models.Common; +using NETCore.Keycloak.Client.Tests.Abstraction; +using NETCore.Keycloak.Client.Tests.Infrastructure; + +namespace NETCore.Keycloak.Client.Tests.Modules.KcAuthorizationTests; + +/// +/// Rejects malformed bearer inputs and fails closed when session validation cannot complete. +/// +[TestClass] +[TestCategory("CoverageUnit")] +public sealed class KcBearerBoundaryUnitTests +{ + /// + /// Invalid input must never grant access or trigger administrative HTTP calls. + /// + /// The malformed header or issuer scenario. + /// Whether the handler explicitly fails the authorization context. + [TestMethod] + [DataRow("no-header", false)] + [DataRow("basic", false)] + [DataRow("no-token", false)] + [DataRow("empty-token", true)] + [DataRow("malformed-token", true)] + [DataRow("no-issuer", true)] + [DataRow("malformed-issuer", true)] + [DataRow("unregistered-realm", true)] + public async Task ShouldNeverAuthorizeMalformedInputs(string scenario, bool failed) + { + var jwt = new JwtSecurityTokenHandler(); + var header = scenario switch + { + "no-header" => string.Empty, + "basic" => "Basic credentials", + "no-token" => "Bearer", + "empty-token" => "Bearer ", + "malformed-token" => "Bearer invalid-token", + "no-issuer" => "Bearer " + jwt.WriteToken(new JwtSecurityToken()), + "malformed-issuer" => "Bearer " + jwt.WriteToken(new JwtSecurityToken(issuer: "not-a-url")), + _ => "Bearer " + jwt.WriteToken(new JwtSecurityToken(issuer: "https://keycloak.test/realms/test")) + }; + var http = new DefaultHttpContext(); + http.Request.Headers.Authorization = header; + var admin = new Mock(MockBehavior.Strict); + using var provider = new ServiceCollection().AddLogging() + .AddSingleton(new HttpContextAccessor { HttpContext = http }) + .AddSingleton(admin.Object).BuildServiceProvider(); + var store = new Mock(); + _ = store.Setup(value => value.GetRealmProtectedResources()).Returns([]); + var requirement = new KcAuthorizationRequirement(store.Object, "orders", "read"); + var context = new AuthorizationHandlerContext([requirement], + new ClaimsPrincipal(new ClaimsIdentity("test")), null); + await new KcTestableBearerAuthorizationHandler(provider).TestHandleRequirementAsync(context, requirement) + .ConfigureAwait(false); + Assert.IsFalse(context.HasSucceeded); + Assert.AreEqual(failed, context.HasFailed); + admin.VerifyNoOtherCalls(); + } + + /// + /// A failed session query denies access, preserves the transport failure and never requests an RPT. + /// + [TestMethod] + public async Task ShouldPreserveSessionQueryFailureAndDenyAccess() + { + var failure = new HttpRequestException("session service unavailable"); + var requests = new List(); + using var transport = new StubHttpTransport(request => + { + var path = request.RequestUri!.AbsolutePath; + requests.Add(path); + if ( path.EndsWith("/sessions", StringComparison.Ordinal) ) + { + throw failure; + } + return Task.FromResult(StubHttpTransport.Json("{\"id\":\"alice\",\"username\":\"alice\"}")); + }); + var jwt = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + issuer: "https://keycloak.test/realms/test", + claims: [new Claim("sub", "alice"), new Claim("sid", "session")])); + var http = new DefaultHttpContext(); + http.Request.Headers.Authorization = "Bearer " + jwt; + var admin = new Mock(); + _ = admin.Setup(value => value.TryGetAdminTokenAsync("test", It.IsAny())) + .ReturnsAsync("admin-token"); + using var provider = new ServiceCollection().AddLogging().AddSingleton(transport) + .AddSingleton(new HttpContextAccessor { HttpContext = http }) + .AddSingleton(admin.Object).BuildServiceProvider(); + var store = new Mock(); + _ = store.Setup(value => value.GetRealmProtectedResources()).Returns([ + new KcRealmProtectedResources { Realm = "test", ProtectedResourceName = "api" } + ]); + var requirement = new KcAuthorizationRequirement(store.Object, "orders", "read"); + var principal = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(ClaimTypes.NameIdentifier, "alice"), new Claim("sid", "session") + ], "test")); + var context = new AuthorizationHandlerContext([requirement], principal, null); + var exception = await Assert.ThrowsExactlyAsync(() => + new KcTestableBearerAuthorizationHandler(provider).TestHandleRequirementAsync(context, requirement)) + .ConfigureAwait(false); + Assert.AreSame(failure, exception.InnerException); + Assert.IsFalse(context.HasSucceeded); + Assert.HasCount(2, requests); + } +} diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcDynamicPolicyUnitTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcDynamicPolicyUnitTests.cs new file mode 100644 index 0000000..ba6957f --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcDynamicPolicyUnitTests.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using NETCore.Keycloak.Client.Authorization.PolicyProviders; +using NETCore.Keycloak.Client.Authorization.Requirements; +using NETCore.Keycloak.Client.Authorization.Store; + +namespace NETCore.Keycloak.Client.Tests.Modules.KcAuthorizationTests; + +/// +/// Creates genuinely unregistered dynamic policies and validates the resulting requirements and cache. +/// +[TestClass] +[TestCategory("CoverageUnit")] +public sealed class KcDynamicPolicyUnitTests +{ + /// + /// Builds and caches requirements for a resource/scope that is absent from AuthorizationOptions. + /// + [TestMethod] + public async Task ShouldBuildAndReuseUnregisteredPolicies() + { + var store = new Mock(); + using var services = new ServiceCollection().AddSingleton(store.Object).BuildServiceProvider(); + var provider = new KcProtectedResourcePolicyProvider(services, Options.Create(new AuthorizationOptions())); + var first = await provider.GetPolicyAsync("orders#read").ConfigureAwait(false); + var second = await provider.GetPolicyAsync("orders#read").ConfigureAwait(false); + Assert.IsNotNull(first); + Assert.AreSame(first, second); + var requirement = first.Requirements.OfType().Single(); + Assert.AreSame(store.Object, requirement.ProtectedResourceStore); + Assert.AreEqual("orders#read", requirement.ToString()); + var write = await provider.GetPolicyAsync("orders#write").ConfigureAwait(false); + Assert.AreNotSame(first, write); + Assert.AreEqual("orders#write", write.Requirements.OfType().Single().ToString()); + } + + /// + /// Concurrent first requests for one policy return the same cached policy without duplicate-key failures. + /// + [TestMethod] + public async Task ShouldShareOnePolicyAcrossConcurrentRequests() + { + var store = new Mock(); + using var services = new ServiceCollection().AddSingleton(store.Object).BuildServiceProvider(); + var provider = new KcProtectedResourcePolicyProvider(services, Options.Create(new AuthorizationOptions())); + var results = await Task.WhenAll(Enumerable.Range(0, 32) + .Select(_ => Task.Run(() => provider.GetPolicyAsync("orders#read")))).ConfigureAwait(false); + foreach ( var result in results ) + { + Assert.AreSame(results[0], result); + } + } +} diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcTokenRenewalUnitTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcTokenRenewalUnitTests.cs new file mode 100644 index 0000000..18480b8 --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcTokenRenewalUnitTests.cs @@ -0,0 +1,244 @@ +using System.Net; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using NETCore.Keycloak.Client.Authorization.Handlers; +using NETCore.Keycloak.Client.Authorization.Store; +using NETCore.Keycloak.Client.Exceptions; +using NETCore.Keycloak.Client.Models.Auth; +using NETCore.Keycloak.Client.Models.Common; +using NETCore.Keycloak.Client.Tests.Infrastructure; + +namespace NETCore.Keycloak.Client.Tests.Modules.KcAuthorizationTests; + +/// +/// Exercises expiry and refresh through the public handler with deterministic time and HTTP responses. +/// +[TestClass] +[TestCategory("CoverageUnit")] +public sealed class KcTokenRenewalUnitTests +{ + /// + /// Refreshes an expired access token, forwards the refresh token and caches the rotated pair. + /// + [TestMethod] + public async Task ShouldRefreshExpiredAccessTokenAndCacheRotatedTokens() + { + var clock = new TestClock(); + var requests = new List(); + using var transport = new StubHttpTransport(async request => + { + var body = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + requests.Add(body); + return StubHttpTransport.Json(requests.Count == 1 ? Token("access-1", "refresh-1") : + requests.Count == 2 ? Token("access-2", "refresh-2") : Token("access-3", "refresh-3")); + }); + using var provider = CreateProvider(clock, transport); + var handler = CreateHandler(provider); + Assert.AreEqual("access-1", await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false)); + Assert.AreEqual("access-1", await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false)); + Assert.HasCount(1, requests); + clock.Advance(TimeSpan.FromSeconds(181)); + Assert.AreEqual("access-2", await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false)); + StringAssert.Contains(requests[1], "grant_type=refresh_token", StringComparison.Ordinal); + StringAssert.Contains(requests[1], "refresh_token=refresh-1", StringComparison.Ordinal); + Assert.AreEqual("access-2", await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false)); + Assert.HasCount(2, requests); + clock.Advance(TimeSpan.FromSeconds(181)); + Assert.AreEqual("access-3", await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false)); + StringAssert.Contains(requests[2], "refresh_token=refresh-2", StringComparison.Ordinal); + } + + /// + /// Falls back to credentials after refresh rejection and rejects failed fallback authentication. + /// + /// Whether password authentication succeeds after refresh rejection. + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public async Task ShouldFallbackAfterRejectedRefresh(bool fallbackSucceeds) + { + var clock = new TestClock(); + var requests = new List(); + using var transport = new StubHttpTransport(async request => + { + requests.Add(await request.Content.ReadAsStringAsync().ConfigureAwait(false)); + return requests.Count switch + { + 1 => StubHttpTransport.Json(Token("access-1", "refresh-1")), + 2 => StubHttpTransport.Json("{\"error\":\"invalid_grant\"}", HttpStatusCode.BadRequest), + _ => fallbackSucceeds ? StubHttpTransport.Json(Token("fallback", "new-refresh")) : + StubHttpTransport.Json("{\"error\":\"invalid_grant\"}", HttpStatusCode.Unauthorized) + }; + }); + using var provider = CreateProvider(clock, transport); + var handler = CreateHandler(provider); + _ = await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false); + clock.Advance(TimeSpan.FromSeconds(181)); + if ( fallbackSucceeds ) + { + Assert.AreEqual("fallback", await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false)); + } + else + { + _ = await Assert.ThrowsExactlyAsync(() => handler.TryGetAdminTokenAsync("test")) + .ConfigureAwait(false); + } + Assert.HasCount(3, requests); + StringAssert.Contains(requests[1], "refresh_token=refresh-1", StringComparison.Ordinal); + StringAssert.Contains(requests[2], "grant_type=password", StringComparison.Ordinal); + } + + /// + /// Uses the refresh lifetime independently of the access lifetime and never reuses an expired refresh token. + /// + /// The refresh token lifetime returned by Keycloak. + /// The grant expected after the access token expires. + [TestMethod] + [DataRow(1800, "refresh_token")] + [DataRow(120, "password")] + [DataRow(0, "password")] + public async Task ShouldRespectRefreshTokenLifetime(int refreshLifetime, string expectedGrant) + { + var clock = new TestClock(); + var requests = new List(); + using var transport = new StubHttpTransport(async request => + { + requests.Add(await request.Content.ReadAsStringAsync().ConfigureAwait(false)); + return StubHttpTransport.Json(Token("access", "refresh", refreshLifetime: refreshLifetime)); + }); + using var provider = CreateProvider(clock, transport); + var handler = CreateHandler(provider); + _ = await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false); + clock.Advance(TimeSpan.FromSeconds(181)); + _ = await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false); + Assert.HasCount(2, requests); + StringAssert.Contains(requests[1], $"grant_type={expectedGrant}", StringComparison.Ordinal); + } + + /// + /// Caches a short-lived token briefly while refreshing before its real expiry. + /// + [TestMethod] + public async Task ShouldBoundSafetyMarginForShortLivedAccessTokens() + { + var clock = new TestClock(); + var requests = 0; + using var transport = new StubHttpTransport(_ => + { + requests++; + return Task.FromResult(StubHttpTransport.Json(Token($"access-{requests}", "refresh", accessLifetime: 60))); + }); + using var provider = CreateProvider(clock, transport); + var handler = CreateHandler(provider); + Assert.AreEqual("access-1", await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false)); + clock.Advance(TimeSpan.FromSeconds(29)); + Assert.AreEqual("access-1", await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false)); + clock.Advance(TimeSpan.FromSeconds(2)); + Assert.AreEqual("access-2", await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false)); + } + + private static string Token(string access, string refresh, int accessLifetime = 300, int refreshLifetime = 1800) => + $$""" + {"access_token":"{{access}}","refresh_token":"{{refresh}}", + "expires_in":{{accessLifetime}},"refresh_expires_in":{{refreshLifetime}}} + """; + + /// + /// Coalesces simultaneous expiry requests so a rotating refresh token is submitted only once. + /// + [TestMethod] + public async Task ShouldShareOneRefreshAcrossConcurrentExpiryRequests() + { + var clock = new TestClock(); + var releaseRefresh = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var refreshes = 0; + using var transport = new StubHttpTransport(async request => + { + var body = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + if ( body.Contains("grant_type=refresh_token", StringComparison.Ordinal) ) + { + _ = Interlocked.Increment(ref refreshes); + await releaseRefresh.Task.ConfigureAwait(false); + return StubHttpTransport.Json(Token("shared-access", "rotated-refresh")); + } + return StubHttpTransport.Json(Token("initial-access", "initial-refresh")); + }); + using var provider = CreateProvider(clock, transport); + var handler = CreateHandler(provider); + _ = await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false); + clock.Advance(TimeSpan.FromSeconds(181)); + var requests = Enumerable.Range(0, 16).Select(_ => handler.TryGetAdminTokenAsync("test")).ToArray(); + var submittedBeforeResponse = Volatile.Read(ref refreshes); + releaseRefresh.SetResult(); + var results = await Task.WhenAll(requests).ConfigureAwait(false); + Assert.AreEqual(1, submittedBeforeResponse, "One refresh token must not be submitted concurrently."); + Assert.AreEqual(1, refreshes); + foreach ( var result in results ) + { + Assert.AreEqual("shared-access", result); + } + } + + /// + /// A canceled waiter cannot release another request's renewal lock or invalidate the shared result. + /// + [TestMethod] + public async Task ShouldCancelRenewalWaitWithoutReleasingAnotherRequestLock() + { + var clock = new TestClock(); + var releaseRefresh = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var refreshes = 0; + using var transport = new StubHttpTransport(async request => + { + var body = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + if ( body.Contains("grant_type=refresh_token", StringComparison.Ordinal) ) + { + _ = Interlocked.Increment(ref refreshes); + await releaseRefresh.Task.ConfigureAwait(false); + return StubHttpTransport.Json(Token("shared-access", "rotated-refresh")); + } + return StubHttpTransport.Json(Token("initial-access", "initial-refresh")); + }); + using var provider = CreateProvider(clock, transport); + var handler = CreateHandler(provider); + _ = await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false); + clock.Advance(TimeSpan.FromSeconds(181)); + var renewal = handler.TryGetAdminTokenAsync("test"); + try + { + _ = await Assert.ThrowsExactlyAsync(() => + handler.TryGetAdminTokenAsync("test", new CancellationToken(canceled: true))).ConfigureAwait(false); + Assert.AreEqual(1, refreshes); + } + finally + { + releaseRefresh.SetResult(); + } + Assert.AreEqual("shared-access", await renewal.ConfigureAwait(false)); + Assert.AreEqual("shared-access", await handler.TryGetAdminTokenAsync("test").ConfigureAwait(false)); + Assert.AreEqual(1, refreshes); + } + + private static ServiceProvider CreateProvider(TimeProvider clock, IHttpClientFactory factory) => + new ServiceCollection().AddLogging().AddSingleton(clock).AddSingleton(factory).BuildServiceProvider(); + + private static KcRealmAdminTokenHandler CreateHandler(IServiceProvider provider) + { + var store = new Mock(); + _ = store.Setup(value => value.GetRealmsAdminConfiguration()).Returns([ + new KcRealmAdminConfiguration + { + Realm = "test", ClientId = "client", KeycloakBaseUrl = "https://keycloak.test", + RealmAdminCredentials = new KcUserLogin { Username = "admin", Password = "test-password" } + } + ]); + return new KcRealmAdminTokenHandler(store.Object, provider); + } + + private sealed class TestClock : TimeProvider + { + private DateTimeOffset _now = DateTimeOffset.UtcNow; + public override DateTimeOffset GetUtcNow() => _now; + public void Advance(TimeSpan elapsed) => _now += elapsed; + } +} diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcClientsTests/KcClientHappyPathTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcClientsTests/KcClientHappyPathTests.cs index 831b40e..b643711 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcClientsTests/KcClientHappyPathTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcClientsTests/KcClientHappyPathTests.cs @@ -483,17 +483,11 @@ public async Task L_ShouldGetAuthorizationManagementPermission() } /// - /// Validates the scenario where setting authorization management permissions for a public client is not allowed in the Keycloak system. + /// Validates public-client management permissions according to the server version. /// [TestMethod] - public async Task N_ShouldNotSetAuthorizationManagementPermissions() + public async Task N_ShouldSetAuthorizationManagementPermissionsAccordingToServerVersion() { - // Skip on KC 26+ — behavior changed, public clients can now have management permissions enabled - if ( GetKcMajorVersion() >= 26 ) - { - Assert.Inconclusive("Skipped on Keycloak 26+ — management permissions behavior changed for public clients."); - } - // Ensure the test client is initialized before proceeding. Assert.IsNotNull(TestClient); @@ -511,9 +505,20 @@ public async Task N_ShouldNotSetAuthorizationManagementPermissions() }) .ConfigureAwait(false); - // Validate the response for the scenario where the setting of permissions fails for a public client. + // Validate the version-specific public-client management permission response. Assert.IsNotNull(setClientAuthorizationPermissionManagementResponse); + // Keycloak 26 supports management permissions on public clients. + if ( GetKcMajorVersion() >= 26 ) + { + Assert.IsFalse(setClientAuthorizationPermissionManagementResponse.IsError); + Assert.IsTrue(setClientAuthorizationPermissionManagementResponse.Response.Enabled); + KcCommonAssertion.AssertResponseMonitoringMetrics( + setClientAuthorizationPermissionManagementResponse.MonitoringMetrics, + HttpStatusCode.OK, HttpMethod.Put); + return; + } + // Ensure the response indicates an error. Assert.IsTrue(setClientAuthorizationPermissionManagementResponse.IsError); diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcCompatibilityTests/Kc2681CompatibilityTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcCompatibilityTests/Kc2681CompatibilityTests.cs new file mode 100644 index 0000000..b596994 --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/Modules/KcCompatibilityTests/Kc2681CompatibilityTests.cs @@ -0,0 +1,983 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using NETCore.Keycloak.Client.Models; +using NETCore.Keycloak.Client.Models.Auth; +using NETCore.Keycloak.Client.Models.Clients; +using NETCore.Keycloak.Client.Models.Common; +using NETCore.Keycloak.Client.Models.Users; +using NETCore.Keycloak.Client.Models.Tokens; +using NETCore.Keycloak.Client.Models.Roles; +using NETCore.Keycloak.Client.Tests.Abstraction; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace NETCore.Keycloak.Client.Tests.Modules.KcCompatibilityTests; + +/// +/// Exercises the existing client against Keycloak 26.8.1 with isolated realms and real authorization decisions. +/// Raw REST calls only prepare fixtures for operations not exposed by the client. +/// +[TestClass] +[TestCategory("Keycloak2681")] +public sealed class Kc2681CompatibilityTests : KcTestingModule, IDisposable +{ + private readonly HttpClient _setupClient = new(); + private string _realm; + private string _adminToken; + private bool _realmCreated; + private bool _fgapV2; + private const string Password = "Test-Only_2681!Password"; + private static readonly string[] RedirectUris = ["http://localhost/unused-test-callback"]; + private static readonly string[] GrantTypes = ["password", "client_credentials", "refresh_token"]; + private static readonly string[] ViewAndManageScopes = ["view", "manage"]; + private static readonly string[] ResetPasswordScopes = ["reset-password"]; + private static readonly string[] ViewScopes = ["view"]; + private static readonly string[] ReadScopes = ["read"]; + + /// + /// Creates a uniquely named realm without modifying the shared integration realm or other installations. + /// + [TestInitialize] + public async Task SetUpAsync() + { + if ( !Version.TryParse(TestEnvironment.KcVersion, out var version) || version < new Version(26, 8, 1) ) + { + Assert.Inconclusive("This regression suite requires Keycloak 26.8.1 or later."); + } + Assert.IsNotNull(TestEnvironment.Admin, "The test configuration must contain master realm admin credentials."); + var token = await KeycloakRestClient.Auth.GetResourceOwnerPasswordTokenAsync(TestEnvironment.AuthRealm, + new KcClientCredentials { ClientId = TestEnvironment.ClientId }, TestEnvironment.Admin) + .ConfigureAwait(false); + Assert.IsFalse(token.IsError, token.ErrorMessage); + _adminToken = token.Response.AccessToken; + var info = await SetupAsync(HttpMethod.Get, "admin/serverinfo").ConfigureAwait(false); + _fgapV2 = info["profileInfo"]?["disabledFeatures"]?.Values() + .Contains("ADMIN_FINE_GRAINED_AUTHZ") == true; + _realm = $"netcore_2681_{Guid.NewGuid():N}"; + _ = await SetupAsync(HttpMethod.Post, "admin/realms", new + { + realm = _realm, + enabled = true, + organizationsEnabled = true, + adminPermissionsEnabled = _fgapV2, + revokeRefreshToken = true, + refreshTokenMaxReuse = 0 + }, HttpStatusCode.Created).ConfigureAwait(false); + _realmCreated = true; + } + + /// + /// Deletes only the realm created by this test, including when an assertion fails. + /// + [TestCleanup] + public async Task CleanUpAsync() + { + try + { + if ( _realmCreated ) + { + _ = await SetupAsync(HttpMethod.Delete, $"admin/realms/{_realm}", null, HttpStatusCode.NoContent) + .ConfigureAwait(false); + } + } + finally + { + Dispose(); + } + } + + /// + /// Releases the fixture HTTP client, including when initialization fails. + /// + public void Dispose() + { + _setupClient.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// Checks the running server rather than trusting only the configured image tag. + /// + [TestMethod] + public async Task ShouldRunThePinnedServerVersion() + { + var server = await SetupAsync(HttpMethod.Get, "admin/serverinfo").ConfigureAwait(false); + Assert.AreEqual(TestEnvironment.KcVersion, server["systemInfo"]?["version"]?.Value()); + } + + /// + /// Uses only the registered authentication method for token, refresh, validation and revocation operations. + /// + /// Whether this client accepts only client_secret_basic. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task ShouldRespectRegisteredClientAuthenticationMethod(bool basic) + { + var credentials = await CreateClientAsync(basic).ConfigureAwait(false); + _ = await CreateUserAsync("alice").ConfigureAwait(false); + var login = new KcUserLogin { Username = "alice", Password = Password }; + var service = await KeycloakRestClient.Auth.GetClientCredentialsTokenAsync(_realm, credentials) + .ConfigureAwait(false); + Assert.IsFalse(service.IsError, service.ErrorMessage); + var token = await KeycloakRestClient.Auth.GetResourceOwnerPasswordTokenAsync(_realm, credentials, login) + .ConfigureAwait(false); + Assert.IsFalse(token.IsError, token.ErrorMessage); + var refresh = await KeycloakRestClient.Auth.RefreshAccessTokenAsync(_realm, credentials, + token.Response.RefreshToken).ConfigureAwait(false); + Assert.IsFalse(refresh.IsError, refresh.ErrorMessage); + var revoke = await KeycloakRestClient.Auth.RevokeRefreshTokenAsync(_realm, credentials, + refresh.Response.RefreshToken).ConfigureAwait(false); + Assert.IsFalse(revoke.IsError, revoke.ErrorMessage); + var replay = await KeycloakRestClient.Auth.RefreshAccessTokenAsync(_realm, credentials, + refresh.Response.RefreshToken).ConfigureAwait(false); + Assert.IsTrue(replay.IsError); + Assert.AreEqual(HttpStatusCode.BadRequest, replay.MonitoringMetrics.StatusCode); + var validation = await KeycloakRestClient.Auth.ValidatePasswordAsync(_realm, credentials, login) + .ConfigureAwait(false); + Assert.IsTrue(validation.Response); + Assert.IsFalse(validation.IsError); + var wrong = new KcClientCredentials + { + ClientId = credentials.ClientId, + Secret = credentials.Secret, + AuthenticationMethod = basic + ? KcClientAuthenticationMethod.ClientSecretPost : KcClientAuthenticationMethod.ClientSecretBasic + }; + var rejected = await KeycloakRestClient.Auth.GetClientCredentialsTokenAsync(_realm, wrong) + .ConfigureAwait(false); + Assert.IsTrue(rejected.IsError, "The server must actually enforce the configured authentication method."); + Assert.AreEqual(HttpStatusCode.Unauthorized, rejected.MonitoringMetrics.StatusCode); + } + + /// + /// Keeps online sessions visible and requests offline sessions explicitly. + /// + [TestMethod] + public async Task ShouldDistinguishOnlineAndOfflineSessions() + { + var credentials = await CreateClientAsync().ConfigureAwait(false); + var userId = await CreateUserAsync("alice").ConfigureAwait(false); + var login = new KcUserLogin { Username = "alice", Password = Password }; + var online = await KeycloakRestClient.Auth.GetResourceOwnerPasswordTokenAsync(_realm, credentials, login) + .ConfigureAwait(false); + Assert.IsFalse(online.IsError, online.ErrorMessage); + var sid = new JwtSecurityTokenHandler().ReadJwtToken(online.Response.AccessToken) + .Claims.Single(claim => claim.Type == "sid").Value; + var sessions = await KeycloakRestClient.Users.SessionsAsync(_realm, _adminToken, userId) + .ConfigureAwait(false); + Assert.IsFalse(sessions.IsError, sessions.ErrorMessage); + Assert.IsTrue(sessions.Response.Any(session => session.Id == sid)); + var offline = await KeycloakRestClient.Auth.GetResourceOwnerPasswordTokenAsync(_realm, credentials, login, + scope: "openid offline_access").ConfigureAwait(false); + Assert.IsFalse(offline.IsError, offline.ErrorMessage); + var refresh = new JwtSecurityTokenHandler().ReadJwtToken(offline.Response.RefreshToken); + Assert.AreEqual("Offline", refresh.Claims.Single(claim => claim.Type == "typ").Value); + var deleted = await KeycloakRestClient.Users.DeleteSessionAsync(_realm, _adminToken, sid) + .ConfigureAwait(false); + Assert.IsFalse(deleted.IsError, deleted.ErrorMessage); + var revoked = await KeycloakRestClient.Auth.RefreshAccessTokenAsync(_realm, credentials, + online.Response.RefreshToken).ConfigureAwait(false); + Assert.IsTrue(revoked.IsError); + var offlineRefresh = await KeycloakRestClient.Auth.RefreshAccessTokenAsync(_realm, credentials, + offline.Response.RefreshToken).ConfigureAwait(false); + Assert.IsFalse(offlineRefresh.IsError, offlineRefresh.ErrorMessage); + } + + /// + /// Ensures strict refresh token rotation rejects reuse and revoked descendants. + /// + [TestMethod] + public async Task ShouldRejectReusedAndRevokedRefreshTokensInStrictMode() + { + var credentials = await CreateClientAsync().ConfigureAwait(false); + _ = await CreateUserAsync("alice").ConfigureAwait(false); + var token = await LoginAsync(credentials, "alice").ConfigureAwait(false); + var first = await KeycloakRestClient.Auth.RefreshAccessTokenAsync(_realm, credentials, token.RefreshToken) + .ConfigureAwait(false); + Assert.IsFalse(first.IsError, first.ErrorMessage); + var reused = await KeycloakRestClient.Auth.RefreshAccessTokenAsync(_realm, credentials, token.RefreshToken) + .ConfigureAwait(false); + Assert.IsTrue(reused.IsError); + Assert.AreEqual(HttpStatusCode.BadRequest, reused.MonitoringMetrics.StatusCode); + + // A fresh login keeps revocation verification independent of the server's replay response. + var fresh = await LoginAsync(credentials, "alice").ConfigureAwait(false); + var rotated = await KeycloakRestClient.Auth.RefreshAccessTokenAsync(_realm, credentials, fresh.RefreshToken) + .ConfigureAwait(false); + Assert.IsFalse(rotated.IsError, rotated.ErrorMessage); + var revoked = await KeycloakRestClient.Auth.RevokeRefreshTokenAsync(_realm, credentials, + rotated.Response.RefreshToken).ConfigureAwait(false); + Assert.IsFalse(revoked.IsError, revoked.ErrorMessage); + var replay = await KeycloakRestClient.Auth.RefreshAccessTokenAsync(_realm, credentials, + rotated.Response.RefreshToken).ConfigureAwait(false); + Assert.IsTrue(replay.IsError); + } + + /// + /// Ensures refresh tokens issued concurrently with revocation cannot continue the revoked session. + /// + [TestMethod] + public async Task ShouldInvalidateRefreshTokensRacingWithRevocation() + { + var credentials = await CreateClientAsync().ConfigureAwait(false); + _ = await CreateUserAsync("alice").ConfigureAwait(false); + for ( var attempt = 0; attempt < 3; attempt++ ) + { + var token = await LoginAsync(credentials, "alice").ConfigureAwait(false); + var refreshTask = KeycloakRestClient.Auth.RefreshAccessTokenAsync(_realm, credentials, token.RefreshToken); + var revokeTask = KeycloakRestClient.Auth.RevokeRefreshTokenAsync(_realm, credentials, token.RefreshToken); + await Task.WhenAll(refreshTask, revokeTask).ConfigureAwait(false); + var refresh = await refreshTask.ConfigureAwait(false); + var revoke = await revokeTask.ConfigureAwait(false); + Assert.IsFalse(revoke.IsError, revoke.ErrorMessage); + var descendant = refresh.IsError ? token.RefreshToken : refresh.Response.RefreshToken; + var replay = await KeycloakRestClient.Auth.RefreshAccessTokenAsync(_realm, credentials, descendant) + .ConfigureAwait(false); + Assert.IsTrue(replay.IsError, "Revocation must invalidate descendants from a concurrent refresh."); + Assert.AreEqual(HttpStatusCode.BadRequest, replay.MonitoringMetrics.StatusCode); + } + } + + /// + /// Prevents manage-users administrators from acquiring realm-admin through privileged group membership. + /// + [TestMethod] + public async Task ShouldPreventManageUsersPrivilegeEscalationThroughGroups() + { + var credentials = await CreateClientAsync().ConfigureAwait(false); + var actor = await CreateUserAsync("delegate").ConfigureAwait(false); + await AssignAdminRoleAsync(actor, "manage-users").ConfigureAwait(false); + var management = await GetManagementClientIdAsync().ConfigureAwait(false); + var root = $"admin/realms/{_realm}"; + _ = await SetupAsync(HttpMethod.Post, $"{root}/groups", new { name = "privileged" }, HttpStatusCode.Created) + .ConfigureAwait(false); + _ = await SetupAsync(HttpMethod.Post, $"{root}/groups", new { name = "ordinary" }, HttpStatusCode.Created) + .ConfigureAwait(false); + var groups = await SetupAsync(HttpMethod.Get, $"{root}/groups").ConfigureAwait(false); + var privileged = groups.Single(value => value["name"]?.Value() == "privileged")["id"]?.Value(); + var ordinary = groups.Single(value => value["name"]?.Value() == "ordinary")["id"]?.Value(); + var role = await SetupAsync(HttpMethod.Get, $"{root}/clients/{management}/roles/realm-admin") + .ConfigureAwait(false); + _ = await SetupAsync(HttpMethod.Post, $"{root}/groups/{privileged}/role-mappings/clients/{management}", + new[] { role }, HttpStatusCode.NoContent).ConfigureAwait(false); + var token = await LoginAsync(credentials, "delegate").ConfigureAwait(false); + var normal = await KeycloakRestClient.Users.AddToGroupAsync(_realm, token.AccessToken, actor, ordinary) + .ConfigureAwait(false); + Assert.IsFalse(normal.IsError, normal.ErrorMessage); + AssertDenied(await KeycloakRestClient.Users.AddToGroupAsync(_realm, token.AccessToken, actor, privileged) + .ConfigureAwait(false)); + AssertDenied(await KeycloakRestClient.Users.CreateAsync(_realm, token.AccessToken, + new KcUser { UserName = "injected_admin", Enabled = true, Groups = ["privileged"] }) + .ConfigureAwait(false)); + var memberships = await KeycloakRestClient.Users.UserGroupsAsync(_realm, _adminToken, actor) + .ConfigureAwait(false); + Assert.IsFalse(memberships.IsError, memberships.ErrorMessage); + Assert.IsFalse(memberships.Response.Any(value => value.Id == privileged)); + } + + /// + /// Filters role-to-group listings to groups a delegated administrator can view. + /// + [TestMethod] + public async Task ShouldHideInaccessibleGroupsInRoleMemberships() + { + var credentials = await CreateClientAsync().ConfigureAwait(false); + var actor = await CreateUserAsync("delegate").ConfigureAwait(false); + await AssignAdminRoleAsync(actor, "view-realm").ConfigureAwait(false); + await AssignAdminRoleAsync(actor, "query-groups").ConfigureAwait(false); + var root = $"admin/realms/{_realm}"; + foreach ( var name in new[] { "visible", "hidden" } ) + { + _ = await SetupAsync(HttpMethod.Post, $"{root}/groups", new { name }, HttpStatusCode.Created) + .ConfigureAwait(false); + } + var groups = await SetupAsync(HttpMethod.Get, $"{root}/groups").ConfigureAwait(false); + var visible = groups.Single(value => value["name"]?.Value() == "visible")["id"]?.Value(); + var role = new KcRole { Name = "membership_role" }; + var created = await KeycloakRestClient.Roles.CreateAsync(_realm, _adminToken, role).ConfigureAwait(false); + Assert.IsFalse(created.IsError, created.ErrorMessage); + var representation = await SetupAsync(HttpMethod.Get, $"{root}/roles/{role.Name}").ConfigureAwait(false); + foreach ( var group in groups ) + { + _ = await SetupAsync(HttpMethod.Post, $"{root}/groups/{group["id"]}/role-mappings/realm", + new[] { representation }, HttpStatusCode.NoContent).ConfigureAwait(false); + } + var management = _fgapV2 + ? (await SetupAsync(HttpMethod.Get, $"{root}/clients?clientId=admin-permissions").ConfigureAwait(false)) + .Single()["id"]?.Value() + : await GetManagementClientIdAsync().ConfigureAwait(false); + var authz = $"{root}/clients/{management}/authz/resource-server"; + JToken permissions = null; + if ( !_fgapV2 ) + { + permissions = await SetupAsync(HttpMethod.Put, $"{root}/groups/{visible}/management/permissions", + new { enabled = true }).ConfigureAwait(false); + } + var policy = await SetupAsync(HttpMethod.Post, $"{authz}/policy/user", new + { + name = "visible-group-policy", + users = new[] { actor }, + logic = "POSITIVE" + }, HttpStatusCode.Created).ConfigureAwait(false); + if ( _fgapV2 ) + { + _ = await SetupAsync(HttpMethod.Post, $"{authz}/permission/scope", new + { + name = "visible-group-permission", + resourceType = "Groups", + resources = new[] { visible }, + scopes = ViewScopes, + policies = new[] { policy["id"]?.Value() } + }, HttpStatusCode.Created).ConfigureAwait(false); + } + else + { + var viewId = permissions["scopePermissions"]?["view"]?.Value(); + var view = await SetupAsync(HttpMethod.Get, $"{authz}/permission/scope/{viewId}").ConfigureAwait(false); + view["policies"] = new JArray(policy["id"]?.Value()); + _ = await SetupAsync(HttpMethod.Put, $"{authz}/permission/scope/{viewId}", view, HttpStatusCode.Created) + .ConfigureAwait(false); + } + var all = await KeycloakRestClient.Roles.GetGroupsAsync(_realm, _adminToken, role.Name).ConfigureAwait(false); + Assert.IsFalse(all.IsError, all.ErrorMessage); + Assert.AreEqual(2, all.Response.Count()); + var token = await LoginAsync(credentials, "delegate").ConfigureAwait(false); + var filtered = await KeycloakRestClient.Roles.GetGroupsAsync(_realm, token.AccessToken, role.Name) + .ConfigureAwait(false); + Assert.IsFalse(filtered.IsError, filtered.ErrorMessage); + CollectionAssert.AreEqual(new[] { visible }, filtered.Response.Select(value => value.Id).ToArray()); + } + + /// + /// Denies ordinary users all credential mutation paths and verifies the password remains unchanged. + /// + [TestMethod] + public async Task ShouldDenyUnprivilegedCredentialMutations() + { + var credentials = await CreateClientAsync().ConfigureAwait(false); + var userId = await CreateUserAsync("alice").ConfigureAwait(false); + _ = await CreateUserAsync("bob").ConfigureAwait(false); + var bob = await LoginAsync(credentials, "bob").ConfigureAwait(false); + var stored = await KeycloakRestClient.Users.GetCredentialsAsync(_realm, _adminToken, userId) + .ConfigureAwait(false); + Assert.IsFalse(stored.IsError, stored.ErrorMessage); + var password = stored.Response.Single(value => value.Type == "password"); + var deleted = await KeycloakRestClient.Users + .DeleteCredentialsAsync(_realm, bob.AccessToken, userId, password.Id).ConfigureAwait(false); + AssertDenied(deleted); + var updated = await KeycloakRestClient.Users.UpdateAsync(_realm, bob.AccessToken, userId, new KcUser + { + Credentials = [new KcCredentials { Type = "password", Value = "Unexpected-New_2681!Password" }] + }).ConfigureAwait(false); + AssertDenied(updated); + var reset = await KeycloakRestClient.Users.ResetPasswordAsync(_realm, bob.AccessToken, userId, + new KcCredentials { Type = "password", Value = "Unexpected-New_2681!Password", Temporary = false }) + .ConfigureAwait(false); + AssertDenied(reset); + _ = await LoginAsync(credentials, "alice").ConfigureAwait(false); + } + + /// + /// Allows delegated access while preserving explicit credential denial in FGAP v2 and view-only access in v1. + /// + [TestMethod] + public async Task ShouldEnforceDelegatedUserManagementScopes() + { + var credentials = await CreateClientAsync().ConfigureAwait(false); + var target = await CreateUserAsync("alice").ConfigureAwait(false); + var actor = await CreateUserAsync("delegate").ConfigureAwait(false); + await AssignAdminRoleAsync(actor, "query-users").ConfigureAwait(false); + var management = _fgapV2 + ? (await SetupAsync(HttpMethod.Get, $"admin/realms/{_realm}/clients?clientId=admin-permissions") + .ConfigureAwait(false)).Single()["id"]?.Value() + : await GetManagementClientIdAsync().ConfigureAwait(false); + var authz = $"admin/realms/{_realm}/clients/{management}/authz/resource-server"; + JToken permissions = null; + if ( !_fgapV2 ) + { + permissions = await SetupAsync(HttpMethod.Put, $"admin/realms/{_realm}/users-management-permissions", + new { enabled = true }).ConfigureAwait(false); + } + var policy = await SetupAsync(HttpMethod.Post, $"{authz}/policy/user", new + { + name = "delegate-user", + users = new[] { actor }, + logic = "POSITIVE" + }, HttpStatusCode.Created).ConfigureAwait(false); + if ( _fgapV2 ) + { + _ = await SetupAsync(HttpMethod.Post, $"{authz}/permission/scope", new + { + name = "delegate-profile-management", + resourceType = "Users", + resources = new[] { target }, + scopes = ViewAndManageScopes, + policies = new[] { policy["id"]?.Value() }, + decisionStrategy = "UNANIMOUS" + }, HttpStatusCode.Created).ConfigureAwait(false); + var denial = await SetupAsync(HttpMethod.Post, $"{authz}/policy/user", new + { + name = "deny-delegate-password", + users = new[] { actor }, + logic = "NEGATIVE" + }, HttpStatusCode.Created).ConfigureAwait(false); + _ = await SetupAsync(HttpMethod.Post, $"{authz}/permission/scope", new + { + name = "deny-password-reset", + resourceType = "Users", + resources = new[] { target }, + scopes = ResetPasswordScopes, + policies = new[] { denial["id"]?.Value() }, + decisionStrategy = "UNANIMOUS" + }, HttpStatusCode.Created).ConfigureAwait(false); + } + else + { + var viewId = permissions["scopePermissions"]?["view"]?.Value(); + var view = await SetupAsync(HttpMethod.Get, $"{authz}/permission/scope/{viewId}").ConfigureAwait(false); + view["policies"] = new JArray(policy["id"]?.Value()); + _ = await SetupAsync(HttpMethod.Put, $"{authz}/permission/scope/{viewId}", view, HttpStatusCode.Created) + .ConfigureAwait(false); + } + + var token = await LoginAsync(credentials, "delegate").ConfigureAwait(false); + var read = await KeycloakRestClient.Users.GetAsync(_realm, token.AccessToken, target).ConfigureAwait(false); + Assert.IsFalse(read.IsError, read.ErrorMessage); + var update = await KeycloakRestClient.Users.UpdateAsync(_realm, token.AccessToken, target, + new KcUser { FirstName = "Updated" }).ConfigureAwait(false); + if ( _fgapV2 ) + { + Assert.IsFalse(update.IsError, update.ErrorMessage); + } + else + { + AssertDenied(update); + } + + var stored = await KeycloakRestClient.Users.GetCredentialsAsync(_realm, _adminToken, target) + .ConfigureAwait(false); + Assert.IsFalse(stored.IsError, stored.ErrorMessage); + var password = stored.Response.Single(value => value.Type == "password"); + AssertDenied(await KeycloakRestClient.Users + .DeleteCredentialsAsync(_realm, token.AccessToken, target, password.Id).ConfigureAwait(false)); + AssertDenied(await KeycloakRestClient.Users.UpdateAsync(_realm, token.AccessToken, target, + new KcUser { Credentials = [new KcCredentials { Type = "password", Value = "Denied-New_2681!Password" }] }) + .ConfigureAwait(false)); + AssertDenied(await KeycloakRestClient.Users.ResetPasswordAsync(_realm, token.AccessToken, target, + new KcCredentials { Type = "password", Value = "Denied-New_2681!Password", Temporary = false }) + .ConfigureAwait(false)); + _ = await LoginAsync(credentials, "alice").ConfigureAwait(false); + } + + /// + /// Prevents a token issued in one realm from changing users in another realm. + /// + [TestMethod] + public async Task ShouldRejectCrossRealmAdminOperations() + { + var credentials = await CreateClientAsync().ConfigureAwait(false); + _ = await CreateUserAsync("alice").ConfigureAwait(false); + var user = await LoginAsync(credentials, "alice").ConfigureAwait(false); + await AssignAdminRoleAsync(new JwtSecurityTokenHandler().ReadJwtToken(user.AccessToken).Subject, "realm-admin") + .ConfigureAwait(false); + user = await LoginAsync(credentials, "alice").ConfigureAwait(false); + var ownRealm = await KeycloakRestClient.Users.ListUserAsync(_realm, user.AccessToken).ConfigureAwait(false); + Assert.IsFalse(ownRealm.IsError, ownRealm.ErrorMessage); + var otherRealm = $"netcore_2681_{Guid.NewGuid():N}"; + _ = await SetupAsync(HttpMethod.Post, "admin/realms", new { realm = otherRealm, enabled = true }, + HttpStatusCode.Created).ConfigureAwait(false); + try + { + var result = await KeycloakRestClient.Users.CreateAsync(otherRealm, user.AccessToken, + new KcUser { UserName = "cross_realm_user", Enabled = true }).ConfigureAwait(false); + AssertDenied(result); + var users = await KeycloakRestClient.Users.ListUserAsync(otherRealm, _adminToken).ConfigureAwait(false); + Assert.IsFalse(users.IsError, users.ErrorMessage); + Assert.IsFalse(users.Response.Any(value => value.UserName == "cross_realm_user")); + } + finally + { + _ = await SetupAsync(HttpMethod.Delete, $"admin/realms/{otherRealm}", null, HttpStatusCode.NoContent) + .ConfigureAwait(false); + } + } + + /// + /// Confirms invalid tokens cannot enumerate users, clients or organizations. + /// + [TestMethod] + public async Task ShouldRejectInvalidBearerTokensAcrossAdminModules() + { + var users = await KeycloakRestClient.Users.ListUserAsync(_realm, "invalid-token").ConfigureAwait(false); + Assert.IsTrue(users.IsError); + Assert.AreEqual(HttpStatusCode.Unauthorized, users.MonitoringMetrics.StatusCode); + var clients = await KeycloakRestClient.Clients.ListAsync(_realm, "invalid-token").ConfigureAwait(false); + Assert.IsTrue(clients.IsError); + Assert.AreEqual(HttpStatusCode.Unauthorized, clients.MonitoringMetrics.StatusCode); + var organizations = await KeycloakRestClient.Organizations.ListAsync(_realm, "invalid-token") + .ConfigureAwait(false); + Assert.IsTrue(organizations.IsError); + Assert.AreEqual(HttpStatusCode.Unauthorized, organizations.MonitoringMetrics.StatusCode); + } + + /// + /// Checks authentication and least-privilege boundaries on every existing administrative module. + /// + /// The administrative module to exercise. + [TestMethod] + [DataRow("Users")] + [DataRow("Clients")] + [DataRow("ClientScopes")] + [DataRow("Groups")] + [DataRow("Roles")] + [DataRow("Organizations")] + [DataRow("AttackDetection")] + [DataRow("ClientInitialAccess")] + [DataRow("RoleMappings")] + [DataRow("ClientRoleMappings")] + [DataRow("ProtocolMappers")] + [DataRow("ScopeMappings")] + public async Task ShouldEnforceAdminModuleTokenBoundaries(string module) + { + var credentials = await CreateClientAsync().ConfigureAwait(false); + var userId = await CreateUserAsync("alice").ConfigureAwait(false); + var token = await LoginAsync(credentials, "alice").ConfigureAwait(false); + var management = await GetManagementClientIdAsync().ConfigureAwait(false); + var scopes = await SetupAsync(HttpMethod.Get, $"admin/realms/{_realm}/client-scopes").ConfigureAwait(false); + var scopeId = scopes.Single(value => value["name"]?.Value() == "profile")["id"]?.Value(); + foreach ( var accessToken in new[] { "invalid-token", token.AccessToken } ) + { + var expected = accessToken == "invalid-token" ? HttpStatusCode.Unauthorized : HttpStatusCode.Forbidden; + var metrics = module switch + { + "Users" => await AssertErrorAsync(KeycloakRestClient.Users.ListUserAsync(_realm, accessToken)) + .ConfigureAwait(false), + "Clients" => await AssertErrorAsync(KeycloakRestClient.Clients.ListAsync(_realm, accessToken)) + .ConfigureAwait(false), + "ClientScopes" => await AssertErrorAsync(KeycloakRestClient.ClientScopes.ListAsync(_realm, accessToken)) + .ConfigureAwait(false), + "Groups" => await AssertErrorAsync(KeycloakRestClient.Groups.ListAsync(_realm, accessToken)) + .ConfigureAwait(false), + "Roles" => await AssertErrorAsync(KeycloakRestClient.Roles.ListAsync(_realm, accessToken)) + .ConfigureAwait(false), + "Organizations" => await AssertErrorAsync(KeycloakRestClient.Organizations + .ListAsync(_realm, accessToken)).ConfigureAwait(false), + "AttackDetection" => await AssertErrorAsync(KeycloakRestClient.AttackDetection + .GetUserStatusAsync(_realm, accessToken, userId)).ConfigureAwait(false), + "ClientInitialAccess" => await AssertErrorAsync(KeycloakRestClient.ClientInitialAccess + .GetInitialAccessAsync(_realm, accessToken)).ConfigureAwait(false), + "RoleMappings" => await AssertErrorAsync(KeycloakRestClient.RoleMappings + .GetUserRoleMappingsAsync(_realm, accessToken, userId)).ConfigureAwait(false), + "ClientRoleMappings" => await AssertErrorAsync(KeycloakRestClient.ClientRoleMappings + .GetUserMappedClientRolesAsync(_realm, accessToken, userId, management)).ConfigureAwait(false), + "ProtocolMappers" => await AssertErrorAsync(KeycloakRestClient.ProtocolMappers + .ListMappersAsync(_realm, accessToken, scopeId)).ConfigureAwait(false), + "ScopeMappings" => await AssertErrorAsync(KeycloakRestClient.ScopeMappings + .ListRolesAssociatedToScopeAsync(_realm, accessToken, scopeId)).ConfigureAwait(false), + _ => throw new ArgumentOutOfRangeException(nameof(module)) + }; + Assert.AreEqual(expected, metrics.StatusCode, $"Unexpected access to {module}."); + } + } + + private static async Task AssertErrorAsync(Task> operation) + { + var response = await operation.ConfigureAwait(false); + Assert.IsTrue(response.IsError); + Assert.IsNull(response.Response, "A denied request must not expose resource data."); + return response.MonitoringMetrics; + } + + /// + /// Keeps same-named UMA resources owned by different users isolated, including multi-permission requests. + /// + [TestMethod] + public async Task ShouldKeepUmaPermissionsIsolatedAcrossResourceOwners() + { + var credentials = await CreateClientAsync().ConfigureAwait(false); + var alice = await CreateUserAsync("alice").ConfigureAwait(false); + var bob = await CreateUserAsync("bob").ConfigureAwait(false); + const string audience = "resource-server"; + _ = await SetupAsync(HttpMethod.Post, $"admin/realms/{_realm}/clients", new + { + clientId = audience, + protocol = "openid-connect", + publicClient = false, + serviceAccountsEnabled = true, + authorizationServicesEnabled = true + }, HttpStatusCode.Created).ConfigureAwait(false); + var clients = await SetupAsync(HttpMethod.Get, $"admin/realms/{_realm}/clients?clientId={audience}") + .ConfigureAwait(false); + var authz = $"admin/realms/{_realm}/clients/{clients.Single()["id"]}/authz/resource-server"; + var first = await SetupAsync(HttpMethod.Post, $"{authz}/resource", new + { + name = "shared-document", + owner = new { id = alice }, + ownerManagedAccess = true, + scopes = new[] { new { name = "read" } } + }, HttpStatusCode.Created).ConfigureAwait(false); + var second = await SetupAsync(HttpMethod.Post, $"{authz}/resource", new + { + name = "shared-document", + owner = new { id = bob }, + ownerManagedAccess = true, + scopes = new[] { new { name = "read" } } + }, HttpStatusCode.Created).ConfigureAwait(false); + var policy = await SetupAsync(HttpMethod.Post, $"{authz}/policy/user", new + { + name = "allow-alice", + users = new[] { alice }, + logic = "POSITIVE" + }, HttpStatusCode.Created).ConfigureAwait(false); + var firstId = first["_id"]?.Value(); + var secondId = second["_id"]?.Value(); + _ = await SetupAsync(HttpMethod.Post, $"{authz}/permission/scope", new + { + name = "read-alice-document", + resources = new[] { firstId }, + scopes = ReadScopes, + policies = new[] { policy["id"]?.Value() }, + decisionStrategy = "UNANIMOUS" + }, HttpStatusCode.Created).ConfigureAwait(false); + var token = await LoginAsync(credentials, "alice").ConfigureAwait(false); + var allowed = await KeycloakRestClient.Auth.GetRequestPartyTokenAsync(_realm, token.AccessToken, audience, + [$"{firstId}#read"]).ConfigureAwait(false); + Assert.IsFalse(allowed.IsError, allowed.ErrorMessage); + var denied = await KeycloakRestClient.Auth.GetRequestPartyTokenAsync(_realm, token.AccessToken, audience, + [$"{secondId}#read"]).ConfigureAwait(false); + Assert.IsTrue(denied.IsError); + Assert.AreEqual(HttpStatusCode.Forbidden, denied.MonitoringMetrics.StatusCode); + var multiple = await KeycloakRestClient.Auth.GetRequestPartyTokenAsync(_realm, token.AccessToken, audience, + [$"{firstId}#read", $"{secondId}#read"]).ConfigureAwait(false); + Assert.IsFalse(multiple.IsError, multiple.ErrorMessage); + var byName = await KeycloakRestClient.Auth.GetRequestPartyTokenAsync(_realm, token.AccessToken, audience, + ["shared-document#read"]).ConfigureAwait(false); + Assert.IsFalse(byName.IsError, byName.ErrorMessage); + foreach ( var response in new[] { multiple, byName } ) + { + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(response.Response.AccessToken); + var authorization = JObject.Parse(jwt.Claims.Single(claim => claim.Type == "authorization").Value); + var permissions = authorization["permissions"]!.ToArray(); + Assert.IsTrue(permissions.Any(value => value["rsid"]?.Value() == firstId)); + Assert.IsFalse(permissions.Any(value => value["rsid"]?.Value() == secondId)); + } + } + + /// + /// Adds, lists, evaluates and removes client-role scope mappings on a client, not a reusable client scope. + /// + [TestMethod] + public async Task ShouldRoundTripClientRoleScopeMappings() + { + var target = await CreateRoleClientAsync().ConfigureAwait(false); + var container = await CreateRoleClientAsync().ConfigureAwait(false); + var role = await CreateClientRoleAsync(container, "scoped_role").ConfigureAwait(false); + var available = AssertSucceeded(await KeycloakRestClient.ScopeMappings + .ListClientRolesAvailableForClientScopeAsync(_realm, _adminToken, target, container).ConfigureAwait(false)); + Assert.IsTrue(available.Any(value => value.Id == role.Id)); + _ = AssertSucceeded(await KeycloakRestClient.ScopeMappings + .AddClientRolesToClientScopeAsync(_realm, _adminToken, target, container, [role]).ConfigureAwait(false), + HttpStatusCode.NoContent); + var mapped = AssertSucceeded(await KeycloakRestClient.ScopeMappings + .ListClientRolesAssociatedToClientScopeAsync(_realm, _adminToken, target, container).ConfigureAwait(false)); + Assert.IsTrue(mapped.Any(value => value.Id == role.Id)); + var effective = AssertSucceeded(await KeycloakRestClient.ScopeMappings + .ListCompositeClientRolesAssociatedToClientScopeAsync(_realm, _adminToken, target, container) + .ConfigureAwait(false)); + Assert.IsTrue(effective.Any(value => value.Id == role.Id)); + _ = AssertSucceeded(await KeycloakRestClient.ScopeMappings + .RemoveClientRolesFromClientScopeAsync(_realm, _adminToken, target, container, [role]) + .ConfigureAwait(false), + HttpStatusCode.NoContent); + var removed = AssertSucceeded(await KeycloakRestClient.ScopeMappings + .ListClientRolesAssociatedToClientScopeAsync(_realm, _adminToken, target, container).ConfigureAwait(false)); + Assert.IsFalse(removed.Any(value => value.Id == role.Id)); + } + + /// + /// Adds, lists, evaluates and removes realm-role scope mappings on a client. + /// + [TestMethod] + public async Task ShouldRoundTripRealmRoleClientScopeMappings() + { + var target = await CreateRoleClientAsync().ConfigureAwait(false); + var role = new KcRole { Name = "scoped_realm_role" }; + _ = AssertSucceeded(await KeycloakRestClient.Roles.CreateAsync(_realm, _adminToken, role) + .ConfigureAwait(false), HttpStatusCode.Created); + role = (await SetupAsync(HttpMethod.Get, $"admin/realms/{_realm}/roles/{role.Name}").ConfigureAwait(false)) + .ToObject(); + var available = AssertSucceeded(await KeycloakRestClient.ScopeMappings + .ListRolesAvailableForClientScopeAsync(_realm, _adminToken, target).ConfigureAwait(false)); + Assert.IsTrue(available.Any(value => value.Id == role.Id)); + _ = AssertSucceeded(await KeycloakRestClient.ScopeMappings + .AddRolesToScopeClientAsync(_realm, _adminToken, target, [role]).ConfigureAwait(false), + HttpStatusCode.NoContent); + var mapped = AssertSucceeded(await KeycloakRestClient.ScopeMappings + .ListRolesAssociatedToClientScopeAsync(_realm, _adminToken, target).ConfigureAwait(false)); + Assert.IsTrue(mapped.Any(value => value.Id == role.Id)); + var effective = AssertSucceeded(await KeycloakRestClient.ScopeMappings + .ListCompositeRolesAssociatedToClientScopeAsync(_realm, _adminToken, target).ConfigureAwait(false)); + Assert.IsTrue(effective.Any(value => value.Id == role.Id)); + _ = AssertSucceeded(await KeycloakRestClient.ScopeMappings + .RemoveRolesFromClientScopeAsync(_realm, _adminToken, target, [role]).ConfigureAwait(false), + HttpStatusCode.NoContent); + var removed = AssertSucceeded(await KeycloakRestClient.ScopeMappings + .ListRolesAssociatedToClientScopeAsync(_realm, _adminToken, target).ConfigureAwait(false)); + Assert.IsFalse(removed.Any(value => value.Id == role.Id)); + } + + /// + /// Distinguishes granted and ungranted roles when evaluating a client's role container. + /// + [TestMethod] + public async Task ShouldEvaluateGrantedAndUngrantedRoleContainers() + { + var target = await CreateRoleClientAsync().ConfigureAwait(false); + var container = await CreateRoleClientAsync().ConfigureAwait(false); + var granted = await CreateClientRoleAsync(container, "granted_role").ConfigureAwait(false); + var ungranted = await CreateClientRoleAsync(container, "ungranted_role").ConfigureAwait(false); + _ = AssertSucceeded(await KeycloakRestClient.ScopeMappings + .AddClientRolesToClientScopeAsync(_realm, _adminToken, target, container, [granted]).ConfigureAwait(false), + HttpStatusCode.NoContent); + var included = AssertSucceeded(await KeycloakRestClient.Clients + .GetScopedProtocolMappersInContainerAsync(_realm, _adminToken, target, container, scope: "openid profile") + .ConfigureAwait(false)).ToList(); + Assert.IsTrue(included.Any(value => value.Id == granted.Id)); + Assert.IsFalse(included.Any(value => value.Id == ungranted.Id)); + var excluded = AssertSucceeded(await KeycloakRestClient.Clients + .GetUnScopedProtocolMappersInContainerAsync(_realm, _adminToken, target, container) + .ConfigureAwait(false)).ToList(); + Assert.IsTrue(excluded.Any(value => value.Id == ungranted.Id)); + Assert.IsFalse(excluded.Any(value => value.Id == granted.Id)); + } + + /// + /// Persists node registration and deletion on a dedicated client without contacting external adapters. + /// + [TestMethod] + public async Task ShouldRegisterAndDeleteClientNodes() + { + var clientId = await CreateRoleClientAsync().ConfigureAwait(false); + _ = AssertSucceeded(await KeycloakRestClient.Clients.RegisterNodeAsync(_realm, _adminToken, clientId, + new Dictionary { ["node"] = "127.0.0.1" }).ConfigureAwait(false), HttpStatusCode.NoContent); + var client = AssertSucceeded(await KeycloakRestClient.Clients.GetAsync(_realm, _adminToken, clientId) + .ConfigureAwait(false)); + Assert.IsNotNull(client.RegisteredNodes); + Assert.IsTrue(JObject.FromObject(client.RegisteredNodes).ContainsKey("127.0.0.1")); + _ = AssertSucceeded(await KeycloakRestClient.Clients.DeleteNodeAsync(_realm, _adminToken, clientId, "127.0.0.1") + .ConfigureAwait(false), HttpStatusCode.NoContent); + client = AssertSucceeded(await KeycloakRestClient.Clients.GetAsync(_realm, _adminToken, clientId) + .ConfigureAwait(false)); + Assert.IsTrue(client.RegisteredNodes == null || + !JObject.FromObject(client.RegisteredNodes).ContainsKey("127.0.0.1")); + } + + /// + /// Processes empty adapter topologies for node checks and revocation propagation. + /// + [TestMethod] + public async Task ShouldTestNodesAndPushRevocationWithoutAdapters() + { + var clientId = await CreateRoleClientAsync().ConfigureAwait(false); + var nodes = AssertSucceeded(await KeycloakRestClient.Clients + .TestAvailableNodesAsync(_realm, _adminToken, clientId).ConfigureAwait(false)); + Assert.IsNotNull(nodes); + Assert.IsFalse(nodes.FailedRequests?.Any() == true); + var revocation = AssertSucceeded(await KeycloakRestClient.Clients + .PushRevocationAsync(_realm, _adminToken, clientId).ConfigureAwait(false)); + Assert.IsNotNull(revocation); + Assert.IsFalse(revocation.FailedRequests?.Any() == true); + } + + /// + /// Removes all composites through the existing parameterless removal API and verifies server state. + /// + [TestMethod] + public async Task ShouldRemoveClientRoleComposites() + { + var client = await CreateRoleClientAsync().ConfigureAwait(false); + var composite = await CreateClientRoleAsync(client, "composite_role").ConfigureAwait(false); + var member = await CreateClientRoleAsync(client, "member_role").ConfigureAwait(false); + _ = AssertSucceeded(await KeycloakRestClient.Roles.AddClientRoleToCompositeAsync(_realm, _adminToken, client, + composite.Name, [member]).ConfigureAwait(false), HttpStatusCode.NoContent); + var before = AssertSucceeded(await KeycloakRestClient.Roles + .GetClientCompositeRolesAsync(_realm, _adminToken, client, composite.Name).ConfigureAwait(false)); + Assert.IsTrue(before.Any(value => value.Id == member.Id)); + _ = AssertSucceeded(await KeycloakRestClient.Roles + .RemoveClientRoleFromCompositeAsync(_realm, _adminToken, client, composite.Name).ConfigureAwait(false), + HttpStatusCode.NoContent); + var after = AssertSucceeded(await KeycloakRestClient.Roles + .GetClientCompositeRolesAsync(_realm, _adminToken, client, composite.Name).ConfigureAwait(false)); + Assert.IsFalse(after.Any()); + _ = AssertSucceeded(await KeycloakRestClient.Roles + .RemoveClientRoleFromCompositeAsync(_realm, _adminToken, client, composite.Name).ConfigureAwait(false), + HttpStatusCode.NoContent); + var denied = await KeycloakRestClient.Roles.RemoveClientRoleFromCompositeAsync(_realm, "invalid-token", client, + composite.Name).ConfigureAwait(false); + Assert.IsTrue(denied.IsError); + Assert.AreEqual(HttpStatusCode.Unauthorized, denied.MonitoringMetrics.StatusCode); + } + + /// + /// Round-trips the legacy FGAP v1 management-permission APIs for groups and realm/client roles. + /// + [TestMethod] + [TestCategory("LegacyAdminPermissions")] + public async Task ShouldRoundTripLegacyManagementPermissions() + { + var root = $"admin/realms/{_realm}"; + _ = await SetupAsync(HttpMethod.Post, $"{root}/groups", new { name = "managed_group" }, HttpStatusCode.Created) + .ConfigureAwait(false); + var groups = await SetupAsync(HttpMethod.Get, $"{root}/groups").ConfigureAwait(false); + var group = groups.Single()["id"]?.Value(); + var enabled = new KcPermissionManagement { Enabled = true }; + var disabled = new KcPermissionManagement { Enabled = false }; + var groupPermission = AssertSucceeded(await KeycloakRestClient.Groups.SetAuthorizationManagementPermissionAsync( + _realm, _adminToken, group, enabled).ConfigureAwait(false)); + Assert.IsTrue(groupPermission.Enabled); + var groupRead = AssertSucceeded(await KeycloakRestClient.Groups.GetAuthorizationManagementPermissionAsync( + _realm, _adminToken, group).ConfigureAwait(false)); + Assert.IsTrue(groupRead.Enabled); + _ = AssertSucceeded(await KeycloakRestClient.Groups.SetAuthorizationManagementPermissionAsync( + _realm, _adminToken, group, disabled).ConfigureAwait(false)); + var role = new KcRole { Name = "managed_role" }; + _ = AssertSucceeded(await KeycloakRestClient.Roles.CreateAsync(_realm, _adminToken, role).ConfigureAwait(false), + HttpStatusCode.Created); + role = (await SetupAsync(HttpMethod.Get, $"{root}/roles/{role.Name}").ConfigureAwait(false)).ToObject(); + Assert.IsTrue(AssertSucceeded(await KeycloakRestClient.Roles.SetAuthorizationPermissionsAsync( + _realm, _adminToken, role.Name, enabled).ConfigureAwait(false)).Enabled); + Assert.IsFalse(AssertSucceeded(await KeycloakRestClient.Roles.SetAuthorizationPermissionsByIdAsync( + _realm, _adminToken, role.Id, disabled).ConfigureAwait(false)).Enabled); + var client = await CreateRoleClientAsync().ConfigureAwait(false); + var clientRole = await CreateClientRoleAsync(client, "managed_client_role").ConfigureAwait(false); + Assert.IsTrue(AssertSucceeded(await KeycloakRestClient.Roles.SetClientRoleAuthorizationPermissionsAsync( + _realm, _adminToken, client, clientRole.Name, enabled).ConfigureAwait(false)).Enabled); + var read = AssertSucceeded(await KeycloakRestClient.Roles.GetClientRoleAuthorizationPermissionsAsync( + _realm, _adminToken, client, clientRole.Name).ConfigureAwait(false)); + Assert.IsTrue(read.Enabled); + _ = AssertSucceeded(await KeycloakRestClient.Roles.SetClientRoleAuthorizationPermissionsAsync( + _realm, _adminToken, client, clientRole.Name, disabled).ConfigureAwait(false)); + } + + private static T AssertSucceeded(KcResponse response, HttpStatusCode expected = HttpStatusCode.OK) + { + Assert.IsFalse(response.IsError, response.ErrorMessage); + Assert.AreEqual(expected, response.MonitoringMetrics.StatusCode); + return response.Response; + } + + private async Task CreateRoleClientAsync() + { + var name = $"roles_{Guid.NewGuid():N}"; + _ = await SetupAsync(HttpMethod.Post, $"admin/realms/{_realm}/clients", new + { + clientId = name, + protocol = "openid-connect", + publicClient = false, + fullScopeAllowed = false + }, HttpStatusCode.Created).ConfigureAwait(false); + var clients = await SetupAsync(HttpMethod.Get, $"admin/realms/{_realm}/clients?clientId={name}") + .ConfigureAwait(false); + return clients.Single()["id"]?.Value(); + } + + private async Task CreateClientRoleAsync(string clientId, string name) + { + _ = AssertSucceeded(await KeycloakRestClient.Roles.CreateClientRoleAsync(_realm, _adminToken, clientId, + new KcRole { Name = name }).ConfigureAwait(false), HttpStatusCode.Created); + return AssertSucceeded(await KeycloakRestClient.Roles.GetClientRolesAsync(_realm, _adminToken, clientId, name) + .ConfigureAwait(false)); + } + + private async Task CreateClientAsync(bool basic = false) + { + var clientId = $"client_{Guid.NewGuid():N}"; + // OIDC registration is used so Keycloak persists token_endpoint_auth_method exactly as specified. + var initial = await KeycloakRestClient.ClientInitialAccess.CreateInitialAccessTokenAsync(_realm, _adminToken, + new KcCreateClientInitialAccess { Count = 1, Expiration = 60 }).ConfigureAwait(false); + Assert.IsFalse(initial.IsError, initial.ErrorMessage); + var registered = await SetupAsync(HttpMethod.Post, $"realms/{_realm}/clients-registrations/openid-connect", new + { + client_name = clientId, + redirect_uris = RedirectUris, + grant_types = GrantTypes, + token_endpoint_auth_method = basic ? "client_secret_basic" : "client_secret_post" + }, HttpStatusCode.Created, initial.Response.Token).ConfigureAwait(false); + return new KcClientCredentials + { + ClientId = registered["client_id"]?.Value(), + Secret = registered["client_secret"]?.Value(), + AuthenticationMethod = basic + ? KcClientAuthenticationMethod.ClientSecretBasic : KcClientAuthenticationMethod.ClientSecretPost + }; + } + + private async Task CreateUserAsync(string username) + { + var response = await KeycloakRestClient.Users.CreateAsync(_realm, _adminToken, new KcUser + { + UserName = username, + Enabled = true, + FirstName = "Test", + LastName = "User", + Email = $"{username}@example.test", + EmailVerified = true, + Credentials = [new KcCredentials { Type = "password", Value = Password, Temporary = false }] + }).ConfigureAwait(false); + Assert.IsFalse(response.IsError, response.ErrorMessage); + var users = await KeycloakRestClient.Users.ListUserAsync(_realm, _adminToken).ConfigureAwait(false); + Assert.IsFalse(users.IsError, users.ErrorMessage); + return users.Response.Single(value => value.UserName == username).Id; + } + + private async Task LoginAsync(KcClientCredentials credentials, string username) + { + var token = await KeycloakRestClient.Auth.GetResourceOwnerPasswordTokenAsync(_realm, credentials, + new KcUserLogin { Username = username, Password = Password }).ConfigureAwait(false); + Assert.IsFalse(token.IsError, token.ErrorMessage); + return token.Response; + } + + private async Task GetManagementClientIdAsync() + { + var clients = await SetupAsync(HttpMethod.Get, $"admin/realms/{_realm}/clients?clientId=realm-management") + .ConfigureAwait(false); + return clients.Single()["id"]?.Value(); + } + + private async Task AssignAdminRoleAsync(string userId, string role) + { + var management = await GetManagementClientIdAsync().ConfigureAwait(false); + var representation = await SetupAsync(HttpMethod.Get, + $"admin/realms/{_realm}/clients/{management}/roles/{role}").ConfigureAwait(false); + _ = await SetupAsync(HttpMethod.Post, + $"admin/realms/{_realm}/users/{userId}/role-mappings/clients/{management}", + new[] { representation }, HttpStatusCode.NoContent).ConfigureAwait(false); + } + + private static void AssertDenied(KcResponse response) + { + Assert.IsTrue(response.IsError); + Assert.AreEqual(HttpStatusCode.Forbidden, response.MonitoringMetrics.StatusCode); + } + + private async Task SetupAsync(HttpMethod method, string path, object body = null, + HttpStatusCode expected = HttpStatusCode.OK, string accessToken = null) + { + using var request = new HttpRequestMessage(method, $"{TestEnvironment.BaseUrl.TrimEnd('/')}/{path}"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken ?? _adminToken); + if ( body != null ) + { + request.Content = new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json"); + } + using var response = await _setupClient.SendAsync(request).ConfigureAwait(false); + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var result = string.IsNullOrWhiteSpace(content) ? null : JToken.Parse(content); + Assert.AreEqual(expected, response.StatusCode, + $"Fixture request failed: {method} {path}; {result?["error_description"]?.Value()}"); + return result; + } +} diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcCompatibilityTests/KcSerializationContractUnitTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcCompatibilityTests/KcSerializationContractUnitTests.cs new file mode 100644 index 0000000..afce01e --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/Modules/KcCompatibilityTests/KcSerializationContractUnitTests.cs @@ -0,0 +1,269 @@ +using NETCore.Keycloak.Client.Models.Clients; +using NETCore.Keycloak.Client.Models.Common; +using NETCore.Keycloak.Client.Models.Keys; +using NETCore.Keycloak.Client.Models.KcEnum; +using NETCore.Keycloak.Client.Models.Roles; +using NETCore.Keycloak.Client.Models.Tokens; +using NETCore.Keycloak.Client.Models.Users; +using NETCore.Keycloak.Client.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace NETCore.Keycloak.Client.Tests.Modules.KcCompatibilityTests; + +/// +/// Verifies wire contracts for optional token claims, policies, keys and nested administrative representations. +/// +[TestClass] +[TestCategory("CoverageUnit")] +public sealed class KcSerializationContractUnitTests +{ + /// + /// Preserves optional OIDC, UMA and certificate-binding claims through deserialization and serialization. + /// + [TestMethod] + public void ShouldPreserveExtendedTokenClaims() + { + const string json = """ + {"category":"ACCESS","at_hash":"access-hash","c_hash":"code-hash","s_hash":"state-hash", + "auth_time":1700000000,"nbf":1700000001,"nonce":"nonce","session_state":"session", + "address":{"country":"CN","formatted":"Test address","locality":"Test city","postal_code":"100000", + "region":"Test region","street_address":"1 Test Street"}, + "authorization":{"permissions":[{"rsid":"resource-id","rsname":"orders","scopes":["read"], + "claims":{"tenant":["test"]}}]},"cnf":{"x5t#S256":"certificate-thumbprint"}, + "realm_access":{"roles":["reader"],"verify_caller":true},"birthdate":"2000-01-01","gender":"unspecified", + "claims_locales":"zh-CN","locale":"zh-CN","middle_name":"Test","nickname":"tester", + "otherClaims":{"tenant":"test"},"phone_number":"+860000000000","phone_number_verified":true, + "picture":"https://example.test/picture","profile":"https://example.test/profile", + "trusted-certs":["test-certificate"],"updated_at":1700000002,"website":"https://example.test", + "zoneinfo":"Asia/Shanghai"} + """; + var token = JsonConvert.DeserializeObject(json); + Assert.AreEqual(KcAccessTokenCategory.Access, token.Category); + Assert.AreEqual("certificate-thumbprint", token.Cnf.X5Ts256); + var permission = token.Authorization.Permissions.Single(); + Assert.AreEqual("resource-id", permission.RsId); + Assert.AreEqual("orders", permission.RsName); + Assert.AreEqual("read", permission.Scopes.Single()); + Assert.IsTrue(permission.Claims.ContainsKey("tenant")); + Assert.IsTrue(token.RealmAccess.VerifyCaller); + Assert.AreEqual("1 Test Street", token.Address.StreetAddress); + Assert.AreEqual("access-hash", token.AtHash); + Assert.AreEqual("code-hash", token.CHash); + Assert.AreEqual("state-hash", token.Hash); + Assert.AreEqual(1700000000, token.AuthTime); + Assert.AreEqual(1700000001L, token.Nbf); + Assert.AreEqual("test", token.OtherClaims["tenant"]); + var serialized = JObject.Parse(JsonConvert.SerializeObject(token)); + // Comparing the original subset catches wrong JSON names, dropped claims and incorrect nested shapes. + foreach ( var property in JObject.Parse(json).Properties() ) + { + Assert.IsTrue(JToken.DeepEquals(property.Value, serialized[property.Name]), property.Name); + } + } + + /// + /// Preserves both positive and negative policy effects and their associated resource/scope references. + /// + /// Keycloak's policy logic value. + /// The matching library value. + [TestMethod] + [DataRow("POSITIVE", KcPolicyLogic.Positive)] + [DataRow("NEGATIVE", KcPolicyLogic.Negative)] + public void ShouldPreserveResourcePolicyEffects(string wireLogic, KcPolicyLogic expected) + { + var json = $$""" + {"id":"policy-id","name":"owner-policy","type":"scope","logic":"{{wireLogic}}", + "decisionStrategy":"UNANIMOUS","description":"Owner permission","owner":"owner-id", + "config":{"applyPolicies":"[\"owner\"]"},"policies":["owner"],"resources":["resource-id"], + "resourcesData":[{"_id":"resource-id","name":"orders"}],"scopes":["read"], + "scopesData":[{"id":"scope-id","name":"read"}]} + """; + var policy = JsonConvert.DeserializeObject(json); + Assert.AreEqual(expected, policy.Logic); + Assert.AreEqual(KcDecisionStrategy.Unanimous, policy.DecisionStrategy); + Assert.AreEqual("policy-id", policy.Id); + Assert.AreEqual("owner-id", policy.Owner); + Assert.AreEqual("scope", policy.Type); + Assert.AreEqual("owner-policy", policy.Name); + Assert.AreEqual("Owner permission", policy.Description); + Assert.AreEqual("owner", policy.Policies.Single()); + Assert.AreEqual("resource-id", policy.Resources.Single()); + Assert.AreEqual("orders", policy.ResourcesData.Single().Name); + Assert.AreEqual("read", policy.Scopes.Single()); + Assert.AreEqual("scope-id", policy.ScopesData.Single().Id); + Assert.IsNotNull(policy.Config); + var wire = JObject.Parse(JsonConvert.SerializeObject(policy)); + Assert.AreEqual(wireLogic, wire["logic"]?.Value()); + Assert.AreEqual("UNANIMOUS", wire["decisionStrategy"]?.Value()); + AssertJsonSubset(JObject.Parse(json), wire); + } + + /// + /// Maps every category using the exact uppercase protocol representation. + /// + /// The library category. + /// The protocol string. + [TestMethod] + [DataRow(KcAccessTokenCategory.Internal, "INTERNAL")] + [DataRow(KcAccessTokenCategory.Access, "ACCESS")] + [DataRow(KcAccessTokenCategory.Id, "ID")] + [DataRow(KcAccessTokenCategory.Admin, "ADMIN")] + [DataRow(KcAccessTokenCategory.AuthorizationResponse, "AUTHORIZATION_RESPONSE")] + [DataRow(KcAccessTokenCategory.Logout, "LOGOUT")] + [DataRow(KcAccessTokenCategory.Userinfo, "USERINFO")] + public void ShouldRoundTripTokenCategories(KcAccessTokenCategory category, string wire) + { + var token = JsonConvert.DeserializeObject($"{{\"category\":\"{wire}\"}}"); + Assert.AreEqual(category, token.Category); + Assert.AreEqual(wire, JObject.FromObject(token)["category"]?.Value()); + } + + /// + /// Global converter registration must convert enums without intercepting ordinary strings. + /// + [TestMethod] + public void ShouldUseEnumConverterWithoutInterceptingStrings() + { + var converter = new KcPolicyLogicConverter(); + Assert.IsTrue(converter.CanConvert(typeof(KcPolicyLogic))); + Assert.IsTrue(converter.CanConvert(typeof(KcPolicyLogic?))); + Assert.IsFalse(converter.CanConvert(typeof(string))); + var settings = new JsonSerializerSettings { Converters = { converter } }; + var json = JsonConvert.SerializeObject(new { logic = KcPolicyLogic.Negative, label = "literal" }, settings); + Assert.AreEqual("NEGATIVE", JObject.Parse(json)["logic"]?.Value()); + Assert.AreEqual("literal", JObject.Parse(json)["label"]?.Value()); + } + + /// + /// Nullable enum input preserves null/unknown values and valid values in array representations. + /// + [TestMethod] + public void ShouldReadNullableEnumArraysAndWriteExplicitNull() + { + var converter = new KcPolicyLogicConverter(); + using var reader = new JsonTextReader(new StringReader("[\"POSITIVE\",\"NEGATIVE\",null,\"FUTURE\"]")); + Assert.IsTrue(reader.Read()); + var result = ( List ) converter.ReadJson(reader, typeof(List), null, + JsonSerializer.CreateDefault()); + Assert.HasCount(4, result); + Assert.AreEqual(KcPolicyLogic.Positive, result[0]); + Assert.AreEqual(KcPolicyLogic.Negative, result[1]); + Assert.IsNull(result[2]); + Assert.IsNull(result[3]); + using var text = new StringWriter(); + using var writer = new JsonTextWriter(text); + writer.WriteStartArray(); + converter.WriteJson(writer, null, JsonSerializer.CreateDefault()); + writer.WriteEndArray(); + Assert.AreEqual("[null]", text.ToString()); + } + + /// + /// Reads active signing-key metadata without confusing key identifiers and key material. + /// + [TestMethod] + public void ShouldPreserveKeyMetadata() + { + const string json = """ + {"active":{"HS256":"hmac-id","RS256":"rsa-id","AES":"aes-id"},"keys":[ + {"algorithm":"RS256","certificate":"test-certificate","kid":"rsa-id","providerId":"provider-id", + "providerPriority":100,"publicKey":"test-public-key","status":"ACTIVE","type":"RSA","use":"SIG"}]} + """; + var metadata = JsonConvert.DeserializeObject(json); + Assert.AreEqual("hmac-id", metadata.Active.Hs256); + Assert.AreEqual("rsa-id", metadata.Active.Rs256); + Assert.AreEqual("aes-id", metadata.Active.Aes); + var key = metadata.Keys.Single(); + Assert.AreEqual(metadata.Active.Rs256, key.Kid); + Assert.AreEqual("RS256", key.Algorithm); + Assert.AreEqual("RSA", key.Type); + Assert.AreEqual("SIG", key.Use); + Assert.AreEqual("ACTIVE", key.Status); + Assert.AreEqual(100, key.ProviderPriority); + Assert.AreEqual("provider-id", key.ProviderId); + Assert.AreEqual("test-public-key", key.PublicKey); + Assert.AreEqual("test-certificate", key.Certificate); + Assert.IsTrue(JToken.DeepEquals(JObject.Parse(json), JObject.FromObject(metadata))); + } + + /// + /// Accepts the previous id input while emitting the resource identifier used by Keycloak. + /// + /// The identifier key in the input representation. + [TestMethod] + [DataRow("_id")] + [DataRow("id")] + public void ShouldPreserveResourceIdsAcrossLegacyAndServerRepresentations(string property) + { + var resource = JsonConvert.DeserializeObject($"{{\"{property}\":\"resource-id\"}}"); + Assert.AreEqual("resource-id", resource.Id); + var json = JObject.FromObject(resource); + Assert.AreEqual("resource-id", json["_id"]?.Value()); + Assert.IsFalse(json.ContainsKey("id")); + } + + /// + /// Preserves nested client-role mappings, realm composites, consent history and federated identity links. + /// + [TestMethod] + public void ShouldPreserveAdministrativeNestedRepresentations() + { + var mapping = JsonConvert.DeserializeObject(""" + {"clientMappings":{"api":{"id":"client-id","client":"api","mappings":[{"id":"role-id","name":"read"}]}}} + """); + var client = mapping.ClientMappings["api"]; + Assert.AreEqual("client-id", client.Id); + Assert.AreEqual("api", client.Client); + Assert.AreEqual("read", client.Mappings.Single().Name); + var composite = JsonConvert.DeserializeObject("{\"realm\":[\"reader\"],\"client\":{}}"); + Assert.AreEqual("reader", composite.Realm.Single()); + Assert.IsEmpty(composite.Client); + var user = JsonConvert.DeserializeObject(""" + {"clientConsents":[{"clientId":"api","grantedClientScopes":["profile"], + "createdDate":1,"lastUpdatedDate":2}], + "federatedIdentities":[{"identityProvider":"oidc","userId":"external-id","userName":"external-user"}]} + """); + var consent = user.ClientConsents.Single(); + Assert.AreEqual("api", consent.ClientId); + Assert.AreEqual("profile", consent.GrantedClientScopes.Single()); + Assert.AreEqual(1L, consent.CreatedDate); + Assert.AreEqual(2L, consent.LastUpdatedDate); + var identity = user.FederatedIdentities.Single(); + Assert.AreEqual("oidc", identity.IdentityProvider); + Assert.AreEqual("external-id", identity.UserId); + Assert.AreEqual("external-user", identity.UserName); + var result = JsonConvert.DeserializeObject( + "{\"successRequests\":[\"node-1\"],\"failedRequests\":[\"node-2\"]}"); + Assert.AreEqual("node-1", result.SuccessRequests.Single()); + Assert.AreEqual("node-2", result.FailedRequests.Single()); + var mapper = JsonConvert.DeserializeObject("{\"consentText\":\"Share profile\"}"); + Assert.AreEqual("Share profile", mapper.ConsentText); + Assert.AreEqual("orders", JsonConvert.DeserializeObject( + "{\"Resource\":\"orders\"}").Resource); + } + + private static void AssertJsonSubset(JToken expected, JToken actual) + { + Assert.IsNotNull(actual, expected.Path); + if ( expected is JObject properties ) + { + foreach ( var property in properties.Properties() ) + { + AssertJsonSubset(property.Value, actual[property.Name]); + } + } + else if ( expected is JArray array ) + { + Assert.AreEqual(array.Count, actual.Count(), expected.Path); + for ( var index = 0; index < array.Count; index++ ) + { + AssertJsonSubset(array[index], actual[index]); + } + } + else + { + Assert.IsTrue(JToken.DeepEquals(expected, actual), expected.Path); + } + } +} diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcUserTests/KcUserTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcUserTests/KcUserTests.cs index fc5618d..a240ff5 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcUserTests/KcUserTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcUserTests/KcUserTests.cs @@ -242,12 +242,6 @@ public async Task E_ShouldPerformUserLogin() [TestMethod] public async Task F_ShouldGetUserSessions() { - // Skip on KC 26+ — password grant creates transient sessions not visible via admin API - if ( GetKcMajorVersion() >= 26 ) - { - Assert.Inconclusive("Skipped on Keycloak 26+ — transient sessions are not returned by the admin sessions API."); - } - // Ensure that the test user exists Assert.IsNotNull(TestUser); @@ -286,12 +280,6 @@ public async Task F_ShouldGetUserSessions() [TestMethod] public async Task G_ShouldDeleteUserSessions() { - // Skip on KC 26+ — depends on F_ShouldGetUserSessions which is skipped - if ( GetKcMajorVersion() >= 26 ) - { - Assert.Inconclusive("Skipped on Keycloak 26+ — transient sessions are not returned by the admin sessions API."); - } - // Ensure that the test user and session exist Assert.IsNotNull(TestUser); Assert.IsNotNull(TestSession); diff --git a/NETCore.Keycloak.Client.Tests/README.md b/NETCore.Keycloak.Client.Tests/README.md index 2fa0d1a..e6761ec 100644 --- a/NETCore.Keycloak.Client.Tests/README.md +++ b/NETCore.Keycloak.Client.Tests/README.md @@ -22,7 +22,7 @@ Key features of the test infrastructure: ### Prerequisites 1. **Python Environment**: - - Python 3.9 or higher + - Python 3.12 or higher (for the current Ansible requirements) - pip (Python package installer) - virtualenv @@ -57,6 +57,49 @@ make install_virtual_env ## Running Tests +### Independent coverage unit tests + +The `CoverageUnit` category runs without Docker or `Assets/testing_environment.json`: + +```bash +dotnet test NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj \ + --configuration Release --filter 'TestCategory=CoverageUnit' --collect 'XPlat Code Coverage' +``` + +These tests resolve real named JWT options, validate signed-token issuer/audience/expiry rules, +exercise dynamic policies and concurrency, and advance an injected `TimeProvider` to verify +admin token renewal and coalescing of concurrent renewals without sleeping. Fake HTTP responses cover transport/encoding failures, +optional OAuth parameters and JSON wire contracts. Tests use explicit protocol fixtures rather +than setting arbitrary properties through reflection. +独立单测无需 Keycloak 配置,重点覆盖认证授权边界、令牌续期与序列化契约;完整覆盖率仍以全量测试报告为准。 + +The authorization handlers optionally resolve `IHttpClientFactory` and `TimeProvider` from DI. +Without registrations, the existing shared HTTP client and system clock are used. +The refresh cache uses `refresh_expires_in`, and its safety margin never exceeds half of a +short token's lifetime. Public method signatures are unchanged. + +The contract tests also protect resource `_id` mapping (with legacy `id` input accepted), +enum converter registration/null values, session-query exception diagnostics and revocation +transport failures. On Windows, run coverage serially or use separate `--artifacts-path` +directories; another test host loading the instrumented output can prevent Coverlet from +restoring that assembly and invalidate the coverage report. + +Local verification on 2026-11-03, .NET 10 Release and Keycloak 26.8.1: + +| Measurement | Before | After | +| --- | --- | --- | +| Full v1 run | 380 passed, no skips | 434 passed, no skips | +| Additional independent tests | — | 54 passed without integration credentials | +| Applicable v2 regressions | 31 passed | 31 passed | +| Line coverage, Cobertura rate | 92.75% | 99.65% | +| Branch coverage | 70.59% | 83.72% | +| Unexecuted source lines | 255 | 12 | + +The 12 remaining lines are internal argument/null guards, the defensive null token-response +return, repeated-disposal handling and the finalizer path. No reflection into private methods, +forced garbage collection or coverage exclusions were added to inflate the result. +本轮新增 54 项独立单测;剩余 12 行为内部校验、防御性返回与资源释放路径,不代表仍有未执行的 HTTP 方法。 + ### Run the complete .NET 10 test suite The complete suite includes integration tests that create, update, and delete Keycloak resources. @@ -76,9 +119,391 @@ In Visual Studio, rebuild the test project after generating or updating the conf run the `NETCore.Keycloak.Client.Tests (net10.0)` group. Run complete test classes: the integration scenarios with `A_` through `Z_` method names share resources created by earlier steps. -Some existing tests intentionally report `Inconclusive` on Keycloak 26+ for transient-session -visibility and public-client management-permission behavior. These version guards are separate -from failures and should be reported as skipped tests. +The Keycloak 26.8.1 suite exercises online session lookup/deletion, Bearer allow/deny decisions, +and public-client management permissions without the previous five version-based skips. +`offline_access` is an **optional** client scope: request it explicitly for offline tokens. +Making it a default scope causes password grants to remove their online session after issuing +an offline token, which invalidates assumptions in online session tests. + +### Keycloak 26.8.1 compatibility / 兼容性验证 + +The target is the existing .NET client surface. The suite does not claim to test every server +feature, UI, external identity provider, LDAP deployment or cluster topology. +本轮完整验证已有客户端功能,包含真实服务端的成功、认证失败和权限拒绝场景。 + +| Existing surface / 已有功能 | Verification / 验证内容 | +| --- | --- | +| Authentication | Public clients; confidential Basic/Post registration and enforcement; password validation; refresh and revocation | +| Users and sessions | CRUD, credentials, groups, online/offline session separation and deletion | +| Clients, client scopes and initial access | CRUD, secrets, scopes, service accounts, sessions and initial access tokens | +| Roles and groups | Realm/client roles, composites, members, role-to-group visibility and privileged group protection | +| Role mappings, client role mappings, scope mappings and protocol mappers | Existing positive workflows plus invalid-token and least-privilege rejection | +| Organizations | Existing CRUD, domains, filtering and serialization; authentication and permission boundaries | +| Attack detection | Existing login failure operations and authentication/permission boundaries | +| UMA/RPT and ASP.NET integration | Multiple permission fields, request-local bearer headers, resource-owner isolation, Bearer allow/deny and claims/policy tests | + +The dedicated `Keycloak2681` regressions create a unique `netcore_2681_*` realm per test and delete +it afterwards. They use master admin credentials only for fixture preparation and cleanup; +the operation under test uses the intended user or delegated administrator token. +Original `A_`–`Z_` tests still use the dedicated shared integration realm. +Do not run either suite against production. + +From this test directory on a dedicated Linux test host (or WSL with Docker configured): + +```bash +make prepare_keycloak_26_environment KC_VERSION=26.8.1 KC_FGAP_VERSION=v1 +dotnet test NETCore.Keycloak.Client.Tests.csproj --configuration Release \ + --logger 'trx;LogFileName=full.trx' --results-directory obj/verification/v1 \ + --collect 'XPlat Code Coverage' +python verify_test_results.py obj/verification/v1 --require-http-method-coverage +make stop + +make prepare_keycloak_26_environment KC_VERSION=26.8.1 KC_FGAP_VERSION=v2 +dotnet test NETCore.Keycloak.Client.Tests.csproj --configuration Release \ + --filter 'TestCategory=Keycloak2681&TestCategory!=LegacyAdminPermissions' --logger 'trx;LogFileName=fgap-v2.trx' \ + --results-directory obj/verification/v2 +python verify_test_results.py obj/verification/v2 +make stop +``` + +The full v1 profile also exercises the client scope-mapping aliases, role-container evaluation, +node registration/deletion, empty adapter topology checks, revocation propagation, composite-role +removal and legacy `/management/permissions` APIs. The coverage gate requires execution of every +declared method in all 13 client interfaces, including async state machines; it does not imply 100% line +or branch coverage. 未执行的已有 HTTP 方法会导致 CI 失败,避免仅凭部分测试通过宣称完整验证。 + +The legacy `/management/permissions` APIs are FGAP v1 APIs; +enabling v2 does not convert them into FGAP v2 configuration APIs. The v2 profile repeats +the dedicated authentication/security regressions and applicable API round trips, including +allowing profile management while explicitly denying password reset. Only the explicit +`LegacyAdminPermissions` category is excluded from this profile. It is not a replacement for the full v1 run. +完整 v1 测试与 v2 安全回归互为补充;CI 要求两者都通过,且不允许失败或跳过。 + +`.github/workflows/keycloak_2681.yml` runs both profiles independently of Sonar credentials, +uses PostgreSQL through the standard Ansible setup, and publishes TRX/Cobertura artifacts. +Local dev-mode/H2 runs do not establish PostgreSQL or cluster compatibility by themselves. + +For an already prepared second server, `KC_TEST_BASE_URL` overrides only the server URL loaded +from the ignored configuration file. The server version is checked through `/admin/serverinfo`. +The override does not provision shared `A_`–`Z_` fixtures: use the standard setup for a full run, +or select `TestCategory=Keycloak2681` for the independent regressions. + +Coverage includes regressions related to credential mutation permissions, privileged group +membership, role/group visibility, UMA resource-owner boundaries and refresh/revoke races +listed in the [26.8.1 release notes](https://www.keycloak.org/2026/10/keycloak-2681-released). +This is functional regression evidence, not a claim of exhaustive security certification. + +To use Basic client authentication, set +`KcClientCredentials.AuthenticationMethod = KcClientAuthenticationMethod.ClientSecretBasic`. +The default remains `ClientSecretPost`, including public clients with no secret. +默认认证方式和现有公开方法签名保持不变;Basic 认证为显式可选项。 + +### Declared API inventory / 公开接口清单 + +The current client exposes **13 modules and 178 public asynchronous methods**. Method counts +include convenience operations and alternate forms; they are not counts of unique HTTP routes. +The earlier figure of 167 counted only ordinary Task-returning methods in Cobertura and omitted +11 async state machines. The verification script now checks the declared interfaces and maps +those state machines back to their methods. This corrects the counting/verification scope; +it does not add 11 new APIs. +当前按公开接口声明核对为 178 个方法,包含组合操作和不同形式的封装,不等于 178 条独立服务端路由。 + +Except for Auth, paths below are relative to `/admin/realms/{realm}`. + +| Module | Methods | Available operations | Representative paths | +| --- | ---: | --- | --- | +| Auth | 7 | Client credentials/password grants; password validation; refresh; access/refresh revocation; UMA/RPT with multiple permissions; public clients and optional Basic/Post authentication. | `/realms/{realm}/protocol/openid-connect/token and /revoke` | +| AttackDetection | 2 | Read a user login-failure status; clear one user or all users' failures. | `/attack-detection/brute-force/users` | +| ClientInitialAccess | 3 | Create, list and delete initial access tokens for client registration. | `/clients-initial-access` | +| Users | 18 | Create/read/update/delete/search/count; email existence; credentials and labels; password reset; group membership/count; online sessions, session deletion and user-wide logout. | `/users; /sessions/{sessionId}` | +| Clients | 33 | CRUD/search; secret generation/read/rotation invalidation; default/optional scopes; example access/ID/user-info evaluation; mapper/scope evaluation; legacy permissions; node registration/deletion/checks; online/offline sessions/counts; revocation push; registration token and service-account user. | `/clients; /clients/{id}/evaluate-scopes; /clients/{id}/nodes` | +| ClientScopes | 5 | Create/read/update/delete/list reusable client scopes. | `/client-scopes` | +| Groups | 10 | CRUD/list/count; child group creation/update; members; legacy management permissions. | `/groups` | +| Roles | 38 | Realm/client role CRUD; lookup by name/ID where exposed; existence; composites and realm/client composite subsets; role users/groups; legacy role management permissions. | `/roles; /roles-by-id; /clients/{id}/roles` | +| RoleMappings | 12 | User/group realm-role assignment, removal, assigned/available/effective role lists and combined mapping representations. | `/users/{id}/role-mappings; /groups/{id}/role-mappings` | +| ClientRoleMappings | 10 | Assign/remove client roles to users/groups; query assigned, available and composite client roles. | `/users/{id}/role-mappings/clients/{client}; group equivalent` | +| ScopeMappings | 20 | Assign/remove realm/client roles and query assigned/available/effective scope mappings for both reusable client scopes and individual clients. | `/client-scopes/{id}/scope-mappings; /clients/{id}/scope-mappings` | +| ProtocolMappers | 14 | Single/bulk creation, list/read/update/delete and protocol-filtered lists for reusable client scopes and individual clients. | `/client-scopes/{id}/protocol-mappers; /clients/{id}/protocol-mappers` | +| Organizations | 6 | Create/read/update/delete/list/count organizations; domain fields are read/written as part of organization representations. | `/organizations` | + +Additional ASP.NET integration covers named JWT options, issuer/audience/lifetime validation, +realm/client role claims, dynamic `resource#scope` policies, admin token caching/renewal and +Bearer session/RPT authorization. Renewal is coalesced per handler/realm to avoid concurrent +submission of a rotating refresh token; cancellation of one waiter leaves the active renewal intact. + +Support boundaries for acceptance: + +- Organization invitations, members, organization groups and identity-provider links are not wrapped. +- Realm-wide CRUD/configuration, identity-provider management, authorization-code/PKCE/PAR, + token introspection, Standard Token Exchange and inbound backchannel logout handlers are not wrapped. +- Legacy `/management/permissions` configuration belongs to FGAP v1. V2 authorization decisions are + regression-tested; raw REST fixture setup is not a public FGAP v2 administration API. +- Protocol-filtered mapper APIs contain SAML selection, but the SAML branch has not been independently + integration-tested. This is not a claim of SAML login/brokering support. +- Node registration/deletion and empty adapter-topology checks/pushes are tested. Real remote Java + adapters, production clusters, LDAP/brokers and a hosted ASP.NET middleware login flow require + deployment-specific validation. Local dev-mode/H2 evidence does not replace Linux/PostgreSQL CI. +- The two legacy methods named `GetScopedProtocolMappersInContainerAsync` and + `GetUnScopedProtocolMappersInContainerAsync` evaluate role scope mappings. They retain their + historical DTO/signatures; tested results include role IDs/names rather than complete role metadata. + +The full declared method list follows for traceability. + +
+Auth: 7 methods + +- `GetClientCredentialsTokenAsync` +- `GetResourceOwnerPasswordTokenAsync` +- `ValidatePasswordAsync` +- `RefreshAccessTokenAsync` +- `RevokeAccessTokenAsync` +- `RevokeRefreshTokenAsync` +- `GetRequestPartyTokenAsync` + +
+ +
+AttackDetection: 2 methods + +- `DeleteUsersLoginFailureAsync` +- `GetUserStatusAsync` + +
+ +
+ClientInitialAccess: 3 methods + +- `CreateInitialAccessTokenAsync` +- `GetInitialAccessAsync` +- `DeleteInitialAccessTokenAsync` + +
+ +
+Users: 18 methods + +- `CreateAsync` +- `IsUserExistsByEmailAsync` +- `ListUserAsync` +- `CountAsync` +- `GetAsync` +- `UpdateAsync` +- `DeleteAsync` +- `GetCredentialsAsync` +- `DeleteCredentialsAsync` +- `UpdateCredentialsLabelAsync` +- `UserGroupsAsync` +- `CountGroupsAsync` +- `AddToGroupAsync` +- `DeleteFromGroupAsync` +- `ResetPasswordAsync` +- `SessionsAsync` +- `DeleteSessionAsync` +- `LogoutFromAllSessionsAsync` + +
+ +
+Clients: 33 methods + +- `CreateAsync` +- `ListAsync` +- `GetAsync` +- `UpdateAsync` +- `DeleteAsync` +- `GenerateNewSecretAsync` +- `GetSecretAsync` +- `GetRotatedSecretAsync` +- `InvalidateRotatedSecretAsync` +- `GetDefaultScopesAsync` +- `AddDefaultScopesAsync` +- `DeleteDefaultScopesAsync` +- `GenerateExampleAccessTokenAsync` +- `GenerateExampleIdTokenAsync` +- `GenerateExampleUserInfoAsync` +- `GetProtocolMappersAsync` +- `GetScopedProtocolMappersInContainerAsync` +- `GetUnScopedProtocolMappersInContainerAsync` +- `GetAuthorizationManagementPermissionAsync` +- `SetAuthorizationManagementPermissionAsync` +- `RegisterNodeAsync` +- `DeleteNodeAsync` +- `CountOfflineSessionsAsync` +- `GetOfflineSessionsAsync` +- `GetOptionalScopesAsync` +- `AddOptionalScopeAsync` +- `DeleteOptionalScopeAsync` +- `PushRevocationAsync` +- `GetRegistrationAccessTokenAsync` +- `GetServiceAccountUserAsync` +- `CountSessionsAsync` +- `TestAvailableNodesAsync` +- `GetUsersSessionsAsync` + +
+ +
+ClientScopes: 5 methods + +- `CreateAsync` +- `ListAsync` +- `GetAsync` +- `UpdateAsync` +- `DeleteAsync` + +
+ +
+Groups: 10 methods + +- `CreateAsync` +- `ListAsync` +- `CountAsync` +- `GetAsync` +- `UpdateAsync` +- `DeleteAsync` +- `SetOrCreateChildAsync` +- `GetAuthorizationManagementPermissionAsync` +- `SetAuthorizationManagementPermissionAsync` +- `GetMembersAsync` + +
+ +
+Roles: 38 methods + +- `CreateAsync` +- `ListAsync` +- `GetAsync` +- `IsRolesExistsAsync` +- `GetByIdAsync` +- `UpdateAsync` +- `UpdateByIdAsync` +- `DeleteAsync` +- `DeleteByIdAsync` +- `AddCompositeAsync` +- `AddCompositeByIdAsync` +- `ListCompositeAsync` +- `ListCompositeByIdAsync` +- `DeleteCompositeAsync` +- `DeleteCompositeByIdAsync` +- `GetClientLevelCompositesAsync` +- `GetClientLevelCompositesByIdAsync` +- `GetRealmLevelCompositesAsync` +- `GetRealmLevelCompositesByIdAsync` +- `GetGroupsAsync` +- `GetAuthorizationPermissionsAsync` +- `GetAuthorizationPermissionsByIdAsync` +- `SetAuthorizationPermissionsAsync` +- `SetAuthorizationPermissionsByIdAsync` +- `GetUserInRoleAsync` +- `CreateClientRoleAsync` +- `ListClientRoleAsync` +- `GetClientRolesAsync` +- `IsClientRoleExistsAsync` +- `UpdateClientRoleAsync` +- `DeleteClientRoleAsync` +- `AddClientRoleToCompositeAsync` +- `GetClientCompositeRolesAsync` +- `RemoveClientRoleFromCompositeAsync` +- `GetGroupsInClientRoleAsync` +- `GetClientRoleAuthorizationPermissionsAsync` +- `SetClientRoleAuthorizationPermissionsAsync` +- `GetUsersInClientRoleAsync` + +
+ +
+RoleMappings: 12 methods + +- `GetGroupRoleMappingsAsync` +- `AddGroupRealmRoleMappingsAsync` +- `ListGroupRealmRoleMappingsAsync` +- `DeleteGroupRealmRoleMappingsAsync` +- `ListGroupAvailableRealmRoleMappingsAsync` +- `ListGroupEffectiveRealmRoleMappingsAsync` +- `GetUserRoleMappingsAsync` +- `AddUserRealmRoleMappingsAsync` +- `ListUserRealmRoleMappingsAsync` +- `DeleteUserRealmRoleMappingsAsync` +- `ListUserAvailableRealmRoleMappingsAsync` +- `ListUserEffectiveRealmRoleMappingsAsync` + +
+ +
+ClientRoleMappings: 10 methods + +- `MapClientRolesToGroupAsync` +- `GetGroupMappedClientRolesAsync` +- `DeleteGroupClientRoleMappingsAsync` +- `GetGroupAvailableClientRolesAsync` +- `GetGroupCompositeClientRolesAsync` +- `MapClientRolesToUserAsync` +- `GetUserMappedClientRolesAsync` +- `DeleteUserClientRoleMappingsAsync` +- `GetUserAvailableClientRolesAsync` +- `GetUserCompositeClientRolesAsync` + +
+ +
+ScopeMappings: 20 methods + +- `AddClientRolesToScopeAsync` +- `ListClientRolesAssociatedToScopeAsync` +- `RemoveClientRolesFromScopeAsync` +- `ListClientRolesAvailableForScopeAsync` +- `ListCompositeClientRolesAssociatedToScopeAsync` +- `AddRolesToScopeAsync` +- `ListRolesAssociatedToScopeAsync` +- `RemoveRolesFromScopeAsync` +- `ListRolesAvailableForScopeAsync` +- `ListCompositeRolesAssociatedToScopeAsync` +- `AddClientRolesToClientScopeAsync` +- `ListClientRolesAssociatedToClientScopeAsync` +- `RemoveClientRolesFromClientScopeAsync` +- `ListClientRolesAvailableForClientScopeAsync` +- `ListCompositeClientRolesAssociatedToClientScopeAsync` +- `AddRolesToScopeClientAsync` +- `ListRolesAssociatedToClientScopeAsync` +- `RemoveRolesFromClientScopeAsync` +- `ListRolesAvailableForClientScopeAsync` +- `ListCompositeRolesAssociatedToClientScopeAsync` + +
+ +
+ProtocolMappers: 14 methods + +- `AddMappersAsync` +- `AddMapperAsync` +- `ListMappersAsync` +- `GetMapperAsync` +- `UpdateMapperAsync` +- `DeleteMapperAsync` +- `ListMappersByProtocolNameAsync` +- `AddClientMappersAsync` +- `AddClientMapperAsync` +- `ListClientMappersAsync` +- `GetClientMapperAsync` +- `UpdateClientMapperAsync` +- `DeleteClientMapperAsync` +- `ListClientMappersByProtocolNameAsync` + +
+ +
+Organizations: 6 methods + +- `CreateAsync` +- `UpdateAsync` +- `DeleteAsync` +- `GetAsync` +- `ListAsync` +- `CountAsync` + +
The test suite uses Cake for build automation and includes several types of tests: diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_test_environment.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_test_environment.yml index c83116d..0b5f50a 100644 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_test_environment.yml +++ b/NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_test_environment.yml @@ -13,4 +13,5 @@ template: src: "templates/testing_environment.json.j2" dest: "{{ current_dir }}/Assets/testing_environment.json" - mode: "0644" + mode: "0600" + no_log: true diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/app_client.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/app_client.yml index 3838f26..1d14592 100644 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/app_client.yml +++ b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/app_client.yml @@ -6,7 +6,6 @@ - "attributes" - "audiences" - "email" - - "offline_access" - "profile" - "roles" - "web-origins" @@ -21,7 +20,6 @@ - "attributes" - "audiences" - "email" - - "offline_access" - "profile" - "roles" - "web-origins" @@ -54,7 +52,9 @@ always_display_in_console: false standard_flow_enabled: false default_client_scopes: "{{ app__client_default_client_scopes }}" + # Offline tokens must be explicitly requested so ordinary logins retain online sessions. optional_client_scopes: + - "offline_access" - "address" - "microprofile-jwt" - "phone" diff --git a/NETCore.Keycloak.Client.Tests/ansible/templates/keycloak.env.j2 b/NETCore.Keycloak.Client.Tests/ansible/templates/keycloak.env.j2 index 673b032..3e373a4 100644 --- a/NETCore.Keycloak.Client.Tests/ansible/templates/keycloak.env.j2 +++ b/NETCore.Keycloak.Client.Tests/ansible/templates/keycloak.env.j2 @@ -3,7 +3,7 @@ KC_FEATURES=declarative-user-profile,scripts,client-policies {% elif kc_version in ['24.0.5-0', '25.0.6'] %} KC_FEATURES=scripts,client-policies {% else %} -KC_FEATURES=scripts,client-policies,admin-fine-grained-authz:v1,organization +KC_FEATURES=scripts,client-policies,admin-fine-grained-authz:{{ kc_fgap_version | default('v1') }},organization {% endif %} KC_DB=postgres diff --git a/NETCore.Keycloak.Client.Tests/ansible/utils/extra/pre_flight.yml b/NETCore.Keycloak.Client.Tests/ansible/utils/extra/pre_flight.yml index b21d5cd..166b7c9 100644 --- a/NETCore.Keycloak.Client.Tests/ansible/utils/extra/pre_flight.yml +++ b/NETCore.Keycloak.Client.Tests/ansible/utils/extra/pre_flight.yml @@ -14,6 +14,12 @@ tags: - always +- name: "Validate the fine-grained admin permission profile" + assert: + that: kc_fgap_version | default('v1') in ['v1', 'v2'] + fail_msg: "kc_fgap_version must be v1 or v2" + changed_when: false + # Check keycloak version - name: "Check keycloak version" assert: @@ -38,4 +44,4 @@ # Set Keycloak version # Sets the Keycloak version to the value provided in 'kc_version', or defaults to '22.0.3' if none is provided. - set_fact: - keycloak_version: "{{ kc_version | default('20.0.3') }}" \ No newline at end of file + keycloak_version: "{{ kc_version | default('20.0.3') }}" diff --git a/NETCore.Keycloak.Client.Tests/ansible/vars/main.yml b/NETCore.Keycloak.Client.Tests/ansible/vars/main.yml index f15e371..4050baf 100644 --- a/NETCore.Keycloak.Client.Tests/ansible/vars/main.yml +++ b/NETCore.Keycloak.Client.Tests/ansible/vars/main.yml @@ -34,6 +34,10 @@ keycloak: {} # Example values: "18.0.0", "latest". kc_version: "" +# Keep legacy management-permission endpoints available for the complete suite. +# The dedicated v2 regression profile validates the supported FGAP v2 authorization model. +kc_fgap_version: "v1" + # Target platform # Specifies the operating system platform for playbook execution. # This variable is set during pre-audit tasks and is used for platform-specific configurations. @@ -61,3 +65,4 @@ supported_keycloak_version: - "24.0.5-0" - "25.0.6" - "26.5.6" +- "26.8.1" diff --git a/NETCore.Keycloak.Client.Tests/verify_test_results.py b/NETCore.Keycloak.Client.Tests/verify_test_results.py new file mode 100644 index 0000000..3056aeb --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/verify_test_results.py @@ -0,0 +1,74 @@ +"""Reject incomplete integration runs, including skipped or undiscovered tests.""" + +import argparse +import re +import xml.etree.ElementTree as ET +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("directory", type=Path) + parser.add_argument("--require-http-method-coverage", action="store_true") + args = parser.parse_args() + reports = list(args.directory.rglob("*.trx")) + if not reports: + raise SystemExit("No TRX reports found.") + total = 0 + for report in reports: + root = ET.parse(report).getroot() + counters = root.find("{*}ResultSummary/{*}Counters") + results = root.findall("{*}Results/{*}UnitTestResult") + if counters is None: + raise SystemExit(f"Missing counters: {report}") + count = int(counters.get("total", "0")) + passed = int(counters.get("passed", "0")) + if count <= 0 or count != passed or count != len(results): + raise SystemExit(f"Incomplete test run: {report} (total={count}, passed={passed})") + if any(result.get("outcome") != "Passed" for result in results): + raise SystemExit(f"Failed, skipped, or aborted tests: {report}") + total += count + print(f"Verified {total} passed tests; no failures or skips.") + if args.require_http_method_coverage: + verify_http_method_coverage(args.directory) + + +def verify_http_method_coverage(directory): + """Require execution of declared client methods, including compiler-generated async state machines.""" + interfaces = Path(__file__).resolve().parent.parent / "NETCore.Keycloak.Client/HttpClients/Abstraction" + expected = set() + for interface in interfaces.glob("IKc*.cs"): + implementation = "NETCore.Keycloak.Client.HttpClients.Implementation." + interface.stem[1:] + names = re.findall(r"(?m)^\s*Task<[^;]*?>\s+(\w+Async)\s*\(", interface.read_text(encoding="utf-8-sig")) + expected.update((implementation, name) for name in names) + if not expected: + raise SystemExit("No declared client methods found for coverage verification.") + modules = {implementation for implementation, _ in expected} + covered = set() + for report in directory.rglob("coverage.cobertura.xml"): + root = ET.parse(report).getroot() + for implementation in root.findall(".//class"): + name = implementation.get("name", "") + module = name.split("/", 1)[0] + if module not in modules: + continue + # Do not mistake an async lambda for its enclosing public method. + state_machine = re.fullmatch(re.escape(module) + r"/<(\w+Async)>d__\d+", name) + for method in implementation.findall("methods/method"): + method_name = method.get("name", "") + if state_machine and method_name == "MoveNext": + identity = (module, state_machine.group(1)) + elif name == module: + identity = (module, method_name) + else: + continue + if identity in expected and any(int(line.get("hits", "0")) > 0 for line in method.findall("lines/line")): + covered.add(identity) + missing = sorted(f"{module}.{name}" for module, name in expected - covered) + if missing: + raise SystemExit("Unexecuted HTTP methods:\n" + "\n".join(missing)) + print(f"Verified execution of {len(expected)} declared client methods across {len(modules)} modules.") + + +if __name__ == "__main__": + main() diff --git a/NETCore.Keycloak.Client/Authorization/Handlers/KcBearerAuthorizationHandler.cs b/NETCore.Keycloak.Client/Authorization/Handlers/KcBearerAuthorizationHandler.cs index 14e7091..7d0e37c 100644 --- a/NETCore.Keycloak.Client/Authorization/Handlers/KcBearerAuthorizationHandler.cs +++ b/NETCore.Keycloak.Client/Authorization/Handlers/KcBearerAuthorizationHandler.cs @@ -31,6 +31,7 @@ public class KcBearerAuthorizationHandler : AuthorizationHandler private readonly IKcRealmAdminTokenHandler _realmAdminTokenHandler; + private readonly IHttpClientFactory _httpClientFactory; /// /// Initializes a new instance of the class. @@ -49,6 +50,7 @@ public KcBearerAuthorizationHandler(IServiceProvider serviceProvider) // Resolve the realm admin token handler for retrieving admin tokens. _realmAdminTokenHandler = scope.ServiceProvider.GetRequiredService(); + _httpClientFactory = scope.ServiceProvider.GetService(); } /// @@ -105,7 +107,7 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext if ( protectedResource != null ) { // Initialize the Keycloak client - var keycloakClient = new KeycloakClient(baseUrl, _logger); + var keycloakClient = new KeycloakClient(baseUrl, _logger, _httpClientFactory); // Validate the user session await ValidateUserSession(context, keycloakClient, realm).ConfigureAwait(false); @@ -227,7 +229,7 @@ private async Task ValidateUserSession(AuthorizationHandlerContext context, Keyc if ( sessionsResponse.IsError ) { throw new KcSessionClosedException($"No active session found for user {userId}.", - userResponse.Exception); + sessionsResponse.Exception); } // Ensure the session ID exists among active sessions diff --git a/NETCore.Keycloak.Client/Authorization/Handlers/KcRealmAdminTokenHandler.cs b/NETCore.Keycloak.Client/Authorization/Handlers/KcRealmAdminTokenHandler.cs index fcb2a9a..055e3b9 100644 --- a/NETCore.Keycloak.Client/Authorization/Handlers/KcRealmAdminTokenHandler.cs +++ b/NETCore.Keycloak.Client/Authorization/Handlers/KcRealmAdminTokenHandler.cs @@ -22,7 +22,11 @@ public sealed class KcRealmAdminTokenHandler : IKcRealmAdminTokenHandler /// /// Stores cached access and refresh tokens to avoid unnecessary API calls. /// - private readonly ConcurrentDictionary _tokensCache; + private readonly ConcurrentDictionary _tokensCache; + private readonly ConcurrentDictionary _tokenLocks = new(); + + private readonly TimeProvider _timeProvider; + private readonly IHttpClientFactory _httpClientFactory; /// /// Provides the Keycloak realm admin configurations. @@ -64,9 +68,11 @@ public KcRealmAdminTokenHandler(KcRealmAdminConfigurationStore realmAdminConfigu using var scope = provider.CreateScope(); _logger = scope.ServiceProvider .GetRequiredService>(); + _timeProvider = scope.ServiceProvider.GetService() ?? TimeProvider.System; + _httpClientFactory = scope.ServiceProvider.GetService(); // Initialize the token cache to store access and refresh tokens for different realms. - _tokensCache = new ConcurrentDictionary(); + _tokensCache = new ConcurrentDictionary(); } /// @@ -77,22 +83,54 @@ public KcRealmAdminTokenHandler(KcRealmAdminConfigurationStore realmAdminConfigu /// A task representing the asynchronous operation. The task result contains the admin access token. /// Thrown if is null or empty. /// Thrown if the token retrieval process fails. - public async Task TryGetAdminTokenAsync(string realm, CancellationToken cancellationToken = default) => - string.IsNullOrWhiteSpace(realm) - ? throw new ArgumentNullException(nameof(realm), $"{nameof(realm)} is required") - : TryGetToken(AccessTokenCachingKey(realm)) is var cachedAccessToken && - !string.IsNullOrWhiteSpace(cachedAccessToken) - ? cachedAccessToken - : TryGetToken(RefreshTokenCachingKey(realm)) is var cachedRefreshToken && - string.IsNullOrWhiteSpace(cachedRefreshToken) && - await RefreshTokenAsync(realm, cachedAccessToken, cancellationToken) - .ConfigureAwait(false) is var token && - !string.IsNullOrWhiteSpace(token) - ? token - : await GetAccessTokenAsync(realm, cancellationToken).ConfigureAwait(false) is var accessToken && - !string.IsNullOrWhiteSpace(accessToken) - ? accessToken - : throw new KcException($"Failed to retrieve Keycloak realm {realm} admin token."); + /// Thrown when waiting for renewal is canceled. + public async Task TryGetAdminTokenAsync(string realm, CancellationToken cancellationToken = default) + { + if ( string.IsNullOrWhiteSpace(realm) ) + { + throw new ArgumentNullException(nameof(realm), $"{nameof(realm)} is required"); + } + + var cachedAccessToken = TryGetToken(AccessTokenCachingKey(realm)); + if ( !string.IsNullOrWhiteSpace(cachedAccessToken) ) + { + return cachedAccessToken; + } + + // Serialize renewal within this handler/realm so rotating refresh tokens are submitted only once. + var tokenLock = _tokenLocks.GetOrAdd(realm, _ => new SemaphoreSlim(1, 1)); + await tokenLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cachedAccessToken = TryGetToken(AccessTokenCachingKey(realm)); + if ( !string.IsNullOrWhiteSpace(cachedAccessToken) ) + { + return cachedAccessToken; + } + + var cachedRefreshToken = TryGetToken(RefreshTokenCachingKey(realm)); + if ( !string.IsNullOrWhiteSpace(cachedRefreshToken) ) + { + var refreshed = await RefreshTokenAsync(realm, cachedRefreshToken, cancellationToken) + .ConfigureAwait(false); + if ( !string.IsNullOrWhiteSpace(refreshed) ) + { + return refreshed; + } + } + + var accessToken = await GetAccessTokenAsync(realm, cancellationToken).ConfigureAwait(false); + if ( !string.IsNullOrWhiteSpace(accessToken) ) + { + return accessToken; + } + throw new KcException($"Failed to retrieve Keycloak realm {realm} admin token."); + } + finally + { + _ = tokenLock.Release(); + } + } /// /// Retrieves an access token for the specified Keycloak realm using the configured client credentials and admin credentials. @@ -139,11 +177,11 @@ private async Task GetAccessTokenAsync(string realm, CancellationToken c // Cache the access token with a reduced expiry time to ensure early refresh. CacheToken(AccessTokenCachingKey(realm), adminTokenResponse.Response.AccessToken, - adminTokenResponse.Response.ExpiresIn - 120); + CacheLifetime(adminTokenResponse.Response.ExpiresIn, 120)); // Cache the refresh token with a different expiry time to support token refresh operations. CacheToken(RefreshTokenCachingKey(realm), adminTokenResponse.Response.RefreshToken, - adminTokenResponse.Response.ExpiresIn - 5 * 60); + CacheLifetime(adminTokenResponse.Response.RefreshExpiresIn, 5 * 60)); // Return the access token. return adminTokenResponse.Response.AccessToken; @@ -198,11 +236,11 @@ private async Task RefreshTokenAsync(string realm, string refreshToken, // Cache the refreshed access token with a reduced expiry time for early refresh. CacheToken(AccessTokenCachingKey(realm), adminTokenResponse.Response.AccessToken, - adminTokenResponse.Response.ExpiresIn - 120); + CacheLifetime(adminTokenResponse.Response.ExpiresIn, 120)); // Cache the refreshed refresh token with a margin to support future refresh operations. CacheToken(RefreshTokenCachingKey(realm), adminTokenResponse.Response.RefreshToken, - adminTokenResponse.Response.ExpiresIn - 5 * 60); + CacheLifetime(adminTokenResponse.Response.RefreshExpiresIn, 5 * 60)); // Return the refreshed access token. return adminTokenResponse.Response.AccessToken; @@ -214,7 +252,7 @@ private async Task RefreshTokenAsync(string realm, string refreshToken, /// The caching key associated with the token. /// The cached token value, or null if the token is expired or not present. private string TryGetToken(string cachingKey) => - _tokensCache.TryGetValue(cachingKey, out var token) && !token.IsExpired && + _tokensCache.TryGetValue(cachingKey, out var token) && token.ExpiresAt > _timeProvider.GetUtcNow() && !string.IsNullOrWhiteSpace(token.Value) ? token.Value : null; @@ -224,7 +262,7 @@ private string TryGetToken(string cachingKey) => /// /// The key used to store the token in the cache. /// The token value to be cached. - /// The expiry time for the cached token in seconds. Defaults to 60 seconds if not specified or less than or equal to zero. + /// The cache lifetime in seconds. Nonpositive values remove the entry. /// /// Thrown when or is null, empty, or consists only of whitespace. /// @@ -242,24 +280,25 @@ private void CacheToken(string cachingKey, string token, long expiryInSeconds = throw new ArgumentNullException(nameof(token), $"{nameof(token)} is required"); } - // If the expiry time is zero or negative, set it to the default of 60 seconds. + // An expired or unspecified token must not acquire an artificial cache lifetime. if ( expiryInSeconds <= 0 ) { - expiryInSeconds = 60; + _ = _tokensCache.TryRemove(cachingKey, out _); + return; } // Cache the token with the specified key and expiry time. - _tokensCache[cachingKey] = new KcCachedToken - { - Value = token, - Expiry = expiryInSeconds - }; + _tokensCache[cachingKey] = (token, _timeProvider.GetUtcNow().AddSeconds(expiryInSeconds)); } /// /// Retrieves the Keycloak client instance for the specified base URL. /// - private KeycloakClient KeycloakClient(string keycloakBaseUrl) => new(keycloakBaseUrl, _logger); + private KeycloakClient KeycloakClient(string keycloakBaseUrl) => new(keycloakBaseUrl, _logger, _httpClientFactory); + + // A safety margin must never extend a short-lived token beyond its actual lifetime. + private static long CacheLifetime(int lifetime, int margin) => + lifetime > 0 ? lifetime - Math.Min(margin, lifetime / 2) : 0; /// /// Builds the caching key for the access token. diff --git a/NETCore.Keycloak.Client/Authorization/PolicyProviders/KcProtectedResourcePolicyProvider.cs b/NETCore.Keycloak.Client/Authorization/PolicyProviders/KcProtectedResourcePolicyProvider.cs index 6546f3d..94e698c 100644 --- a/NETCore.Keycloak.Client/Authorization/PolicyProviders/KcProtectedResourcePolicyProvider.cs +++ b/NETCore.Keycloak.Client/Authorization/PolicyProviders/KcProtectedResourcePolicyProvider.cs @@ -24,8 +24,7 @@ public class KcProtectedResourcePolicyProvider : DefaultAuthorizationPolicyProvi /// /// Cache authorization policies /// - private readonly IDictionary _cachedPolices = - new ConcurrentDictionary(); + private readonly ConcurrentDictionary _cachedPolices = new(); /// /// Construct provider @@ -84,8 +83,6 @@ public override async Task GetPolicyAsync(string policyName var policy = builder.Build(); // Cache built policy - _cachedPolices.Add(policyName, policy); - - return policy; + return _cachedPolices.GetOrAdd(policyName, policy); } } diff --git a/NETCore.Keycloak.Client/HttpClients/Abstraction/IKcRoles.cs b/NETCore.Keycloak.Client/HttpClients/Abstraction/IKcRoles.cs index a0e6580..33f77c1 100644 --- a/NETCore.Keycloak.Client/HttpClients/Abstraction/IKcRoles.cs +++ b/NETCore.Keycloak.Client/HttpClients/Abstraction/IKcRoles.cs @@ -631,7 +631,8 @@ Task>> GetClientCompositeRolesAsync(string realm, string clientId, string name, CancellationToken cancellationToken = default); /// - /// Removes composite roles associated with a specific client role. + /// Removes all currently assigned composite roles associated with a specific client role. + /// Reads the current composites first, then supplies them in the DELETE request body required by Keycloak. /// /// DELETE /{realm}/clients/{client-uuid}/roles/{role-name}/composites /// diff --git a/NETCore.Keycloak.Client/HttpClients/Implementation/KcAuth.cs b/NETCore.Keycloak.Client/HttpClients/Implementation/KcAuth.cs index 45cb43a..69e72e9 100644 --- a/NETCore.Keycloak.Client/HttpClients/Implementation/KcAuth.cs +++ b/NETCore.Keycloak.Client/HttpClients/Implementation/KcAuth.cs @@ -1,4 +1,5 @@ using System.Net.Http.Headers; +using System.Text; using Microsoft.Extensions.Logging; using NETCore.Keycloak.Client.Constants; using NETCore.Keycloak.Client.HttpClients.Abstraction; @@ -43,16 +44,16 @@ public async Task> GetClientCredentialsToken var client = CreateHttpClient(); // Create the form content with the client credentials grant type. - using var form = new FormUrlEncodedContent( - new Dictionary + var formData = new Dictionary { { "client_id", clientCredentials.ClientId }, { "client_secret", clientCredentials.Secret }, { "grant_type", "client_credentials" } - }); + }; // Send the POST request to the token endpoint with the form content. - return await client.PostAsync(new Uri(tokenEndpoint), form, cancellationToken) + using var request = CreateAuthenticatedRequest(tokenEndpoint, clientCredentials, formData); + return await client.SendAsync(request, cancellationToken) .ConfigureAwait(false); }, new KcHttpMonitoringFallbackModel { @@ -145,11 +146,9 @@ public async Task> GetResourceOwnerPasswordT formData.Add("resource", resource); } - // Create the form content with the prepared data. - using var form = new FormUrlEncodedContent(formData); - // Send the POST request to the token endpoint with the form content. - return await client.PostAsync(new Uri(tokenEndpoint), form, cancellationToken) + using var request = CreateAuthenticatedRequest(tokenEndpoint, clientCredentials, formData); + return await client.SendAsync(request, cancellationToken) .ConfigureAwait(false); }, new KcHttpMonitoringFallbackModel { @@ -274,17 +273,17 @@ public async Task> RefreshAccessTokenAsync( var client = CreateHttpClient(); // Prepare the form data for the refresh token request. - using var form = new FormUrlEncodedContent( - new Dictionary + var formData = new Dictionary { { "client_id", clientCredentials.ClientId }, { "client_secret", clientCredentials.Secret }, { "grant_type", "refresh_token" }, { "refresh_token", refreshToken } - }); + }; // Send the POST request to the token endpoint with the form content. - return await client.PostAsync(new Uri(tokenEndpoint), form, cancellationToken) + using var request = CreateAuthenticatedRequest(tokenEndpoint, clientCredentials, formData); + return await client.SendAsync(request, cancellationToken) .ConfigureAwait(false); }, new KcHttpMonitoringFallbackModel { @@ -352,17 +351,17 @@ public async Task> RevokeAccessTokenAsync( var client = CreateHttpClient(); // Prepare the form data for the token revocation request. - using var form = new FormUrlEncodedContent( - new Dictionary + var formData = new Dictionary { { "client_id", clientCredentials.ClientId }, { "client_secret", clientCredentials.Secret }, { "token", accessToken }, { "token_type_hint", "access_token" } - }); + }; // Send the POST request to the token revocation endpoint with the form content. - return await client.PostAsync(new Uri(tokenRevocationEndpoint), form, cancellationToken) + using var request = CreateAuthenticatedRequest(tokenRevocationEndpoint, clientCredentials, formData); + return await client.SendAsync(request, cancellationToken) .ConfigureAwait(false); }, new KcHttpMonitoringFallbackModel { @@ -375,10 +374,11 @@ public async Task> RevokeAccessTokenAsync( { Response = tokenRevocationResponse?.ResponseMessage?.IsSuccessStatusCode == true, IsError = tokenRevocationResponse?.ResponseMessage?.IsSuccessStatusCode != true, + Exception = tokenRevocationResponse?.Exception, ErrorMessage = tokenRevocationResponse?.ResponseMessage != null ? await tokenRevocationResponse.ResponseMessage.Content.ReadAsStringAsync(cancellationToken) .ConfigureAwait(false) - : null, + : tokenRevocationResponse?.Exception?.Message, MonitoringMetrics = await KcHttpApiMonitoringMetrics .MapFromHttpRequestExecutionResult(tokenRevocationResponse, cancellationToken) .ConfigureAwait(false) @@ -440,17 +440,17 @@ public async Task> RevokeRefreshTokenAsync( var client = CreateHttpClient(); // Prepare the form data for the token revocation request. - using var form = new FormUrlEncodedContent( - new Dictionary + var formData = new Dictionary { { "client_id", clientCredentials.ClientId }, { "client_secret", clientCredentials.Secret }, { "token", refreshToken }, { "token_type_hint", "refresh_token" } - }); + }; // Send the POST request to the token revocation endpoint with the form content. - return await client.PostAsync(new Uri(tokenRevocationEndpoint), form, cancellationToken) + using var request = CreateAuthenticatedRequest(tokenRevocationEndpoint, clientCredentials, formData); + return await client.SendAsync(request, cancellationToken) .ConfigureAwait(false); }, new KcHttpMonitoringFallbackModel { @@ -463,10 +463,11 @@ public async Task> RevokeRefreshTokenAsync( { Response = tokenRevocationResponse?.ResponseMessage?.IsSuccessStatusCode == true, IsError = tokenRevocationResponse?.ResponseMessage?.IsSuccessStatusCode != true, + Exception = tokenRevocationResponse?.Exception, ErrorMessage = tokenRevocationResponse?.ResponseMessage != null ? await tokenRevocationResponse.ResponseMessage.Content.ReadAsStringAsync(cancellationToken) .ConfigureAwait(false) - : null, + : tokenRevocationResponse?.Exception?.Message, MonitoringMetrics = await KcHttpApiMonitoringMetrics .MapFromHttpRequestExecutionResult(tokenRevocationResponse, cancellationToken) .ConfigureAwait(false) @@ -523,10 +524,10 @@ public async Task> GetRequestPartyTokenAsync var client = CreateHttpClient(); // Prepare the form data for the RPT request. - var formData = new Dictionary + var formData = new List> { - { "grant_type", "urn:ietf:params:oauth:grant-type:uma-ticket" }, // Specify the UMA grant type. - { "audience", audience } // Specify the audience. + new("grant_type", "urn:ietf:params:oauth:grant-type:uma-ticket"), // Specify the UMA grant type. + new("audience", audience) // Specify the audience. }; // Convert the permissions collection to a list, if provided. @@ -537,7 +538,7 @@ public async Task> GetRequestPartyTokenAsync { foreach ( var permission in permissionList ) { - formData.Add("permission", permission); + formData.Add(new KeyValuePair("permission", permission)); } } @@ -582,4 +583,25 @@ public async Task> GetRequestPartyTokenAsync }; } } + + // Client authentication belongs to this request, never to shared HttpClient headers. + private static HttpRequestMessage CreateAuthenticatedRequest(string endpoint, + KcClientCredentials credentials, Dictionary formData) + { + var request = new HttpRequestMessage(HttpMethod.Post, endpoint); + if ( credentials.AuthenticationMethod == KcClientAuthenticationMethod.ClientSecretBasic ) + { + _ = formData.Remove("client_id"); + _ = formData.Remove("client_secret"); + // RFC 6749 section 2.3.1 requires form encoding each credential before Base64 encoding. + var clientId = Uri.EscapeDataString(credentials.ClientId).Replace("%20", "+", StringComparison.Ordinal); + var secret = Uri.EscapeDataString(credentials.Secret ?? string.Empty) + .Replace("%20", "+", StringComparison.Ordinal); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{secret}"))); + } + + request.Content = new FormUrlEncodedContent(formData); + return request; + } } diff --git a/NETCore.Keycloak.Client/HttpClients/Implementation/KcRoles.cs b/NETCore.Keycloak.Client/HttpClients/Implementation/KcRoles.cs index 7df4cbf..86ec290 100644 --- a/NETCore.Keycloak.Client/HttpClients/Implementation/KcRoles.cs +++ b/NETCore.Keycloak.Client/HttpClients/Implementation/KcRoles.cs @@ -1017,7 +1017,7 @@ public Task>> GetClientCompositeRolesAsync( } /// - public Task> RemoveClientRoleFromCompositeAsync( + public async Task> RemoveClientRoleFromCompositeAsync( string realm, string accessToken, string clientId, @@ -1033,17 +1033,32 @@ public Task> RemoveClientRoleFromCompositeAsync( // Validate that the name parameter is not null, empty, or whitespace. ValidateRequiredString(nameof(name), name); + // Keycloak requires the roles being removed in the DELETE body. The existing API removes all composites. + var composites = await GetClientCompositeRolesAsync(realm, accessToken, clientId, name, cancellationToken) + .ConfigureAwait(false); + if ( composites.IsError ) + { + return new KcResponse + { + IsError = true, + ErrorMessage = composites.ErrorMessage, + Exception = composites.Exception, + MonitoringMetrics = composites.MonitoringMetrics + }; + } + // Construct the URL for removing composite roles. var url = $"{BaseUrl}/{realm}/clients/{clientId}/roles/{name}/composites"; // Process the request to remove the composite roles. - return ProcessRequestAsync( + return await ProcessRequestAsync( url, HttpMethod.Delete, accessToken, "Unable to remove composite from the client roles", + composites.Response ?? [], cancellationToken: cancellationToken - ); + ).ConfigureAwait(false); } /// diff --git a/NETCore.Keycloak.Client/Models/Auth/KcClientAuthenticationMethod.cs b/NETCore.Keycloak.Client/Models/Auth/KcClientAuthenticationMethod.cs new file mode 100644 index 0000000..696a2c0 --- /dev/null +++ b/NETCore.Keycloak.Client/Models/Auth/KcClientAuthenticationMethod.cs @@ -0,0 +1,17 @@ +namespace NETCore.Keycloak.Client.Models.Auth; + +/// +/// Defines client authentication for OpenID Connect token and revocation requests. +/// +public enum KcClientAuthenticationMethod +{ + /// + /// Sends credentials using client_secret_post. Public clients may omit the secret. + /// + ClientSecretPost = 0, + + /// + /// Sends form-encoded credentials in the HTTP Basic header using client_secret_basic. + /// + ClientSecretBasic = 1 +} diff --git a/NETCore.Keycloak.Client/Models/Auth/KcClientCredentials.cs b/NETCore.Keycloak.Client/Models/Auth/KcClientCredentials.cs index cc14929..a528a1d 100644 --- a/NETCore.Keycloak.Client/Models/Auth/KcClientCredentials.cs +++ b/NETCore.Keycloak.Client/Models/Auth/KcClientCredentials.cs @@ -26,14 +26,27 @@ public class KcClientCredentials [JsonProperty("client_secret")] public string Secret { get; set; } + /// + /// Gets or sets client authentication for token and revocation requests. + /// Defaults to form authentication to preserve existing confidential and public client behavior. + /// + [JsonIgnore] + [System.Text.Json.Serialization.JsonIgnore] + public KcClientAuthenticationMethod AuthenticationMethod { get; set; } + /// /// Validates the Keycloak client credentials. /// /// - /// Thrown when the is null, empty, or consists only of whitespace. + /// Thrown when is blank or is unknown. /// public void Validate() { + if ( !Enum.IsDefined(AuthenticationMethod) ) + { + throw new KcException($"{nameof(AuthenticationMethod)} is invalid"); + } + if ( string.IsNullOrWhiteSpace(ClientId) ) { throw new KcException($"{nameof(ClientId)} is required"); diff --git a/NETCore.Keycloak.Client/Models/Clients/KcClientResource.cs b/NETCore.Keycloak.Client/Models/Clients/KcClientResource.cs index 807d17d..fb24a30 100644 --- a/NETCore.Keycloak.Client/Models/Clients/KcClientResource.cs +++ b/NETCore.Keycloak.Client/Models/Clients/KcClientResource.cs @@ -14,9 +14,13 @@ public class KcClientResource /// /// A string representing the resource ID. /// - [JsonProperty("id")] + [JsonProperty("_id")] public string Id { get; set; } + // Accept the older library representation while emitting Keycloak's resource identifier name. + [JsonProperty("id")] + private string LegacyId { set => Id ??= value; } + /// /// Gets or sets the attributes associated with the resource. /// diff --git a/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj b/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj index 0f5b16f..dc793d2 100644 --- a/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj +++ b/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj @@ -10,10 +10,10 @@ keycloak;oauth2;authentication;authorization;openid-connect;oidc;oidc-provider;fapi;fapi-client;user-managed-access;financial-security black_cockpit.png MIT - https://github.com/Black-Cockpit/NETCore.Keycloak.git + https://github.com/sugarst/NETCore.Keycloak.git git README.md - https://github.com/Black-Cockpit/NETCore.Keycloak + https://github.com/sugarst/NETCore.Keycloak net10.0 latest enable diff --git a/NETCore.Keycloak.Client/Serialization/KcJsonEnumConverter.cs b/NETCore.Keycloak.Client/Serialization/KcJsonEnumConverter.cs index 03019fe..80651ea 100644 --- a/NETCore.Keycloak.Client/Serialization/KcJsonEnumConverter.cs +++ b/NETCore.Keycloak.Client/Serialization/KcJsonEnumConverter.cs @@ -32,6 +32,7 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s if ( value == null ) { + writer.WriteNull(); return; } @@ -60,5 +61,6 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist } /// > - public override bool CanConvert(Type objectType) => objectType == typeof(string); + public override bool CanConvert(Type objectType) => + objectType == typeof(TEnum) || objectType == typeof(TEnum?); } diff --git a/README.md b/README.md index 4e07f76..6055310 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,11 @@ | 25.x | ✅ | | 24.x | ✅ | +Keycloak **26.8.1** is the pinned integration target for the .NET 10 client. +The [compatibility test matrix](NETCore.Keycloak.Client.Tests/README.md#keycloak-2681-compatibility--兼容性验证) +covers the existing API surface with a full FGAP v1 run and separate FGAP v2 security regressions. +Keycloak 26.8.1 为当前固定测试版本;验证范围为已有客户端 API,详见测试矩阵。 + > **Note:** Keycloak versions 20.x through 23.x and .NET 6.0/7.0 were supported in previous releases (v1.x). If you need support for these older versions, please use [v1.0.2](https://www.nuget.org/packages/Keycloak.NETCore.Client/1.0.2). ## 🌟 Key Features From b41e9fbb319111215c751245b286a77e52a5e79e Mon Sep 17 00:00:00 2001 From: jeff Date: Wed, 4 Nov 2026 10:08:00 +0800 Subject: [PATCH 3/7] =?UTF-8?q?Use=20SLNX=20solution=20only=20/=20?= =?UTF-8?q?=E4=BB=85=E4=BD=BF=E7=94=A8=20SLNX=20=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the legacy SLN solution, track the existing SLNX solution, update all five Cake solution references, and document the build entry point. 保留相同项目和功能代码。Validated Release solution build and 54 independent unit tests; zero test failures or skips. --- .../build_test_analyse.cake | 2 +- NETCore.Keycloak.Client.Tests/e2e_test.cake | 2 +- NETCore.Keycloak.sln | 22 ------------------- NETCore.Keycloak.slnx | 4 ++++ README.md | 6 +++++ build.cake | 8 +++---- 6 files changed, 16 insertions(+), 28 deletions(-) delete mode 100644 NETCore.Keycloak.sln create mode 100644 NETCore.Keycloak.slnx diff --git a/NETCore.Keycloak.Client.Tests/build_test_analyse.cake b/NETCore.Keycloak.Client.Tests/build_test_analyse.cake index b49b182..043bc47 100644 --- a/NETCore.Keycloak.Client.Tests/build_test_analyse.cake +++ b/NETCore.Keycloak.Client.Tests/build_test_analyse.cake @@ -31,7 +31,7 @@ Task("Test") } // Resolve solution path to absolute for dotCover compatibility - var slnPath = MakeAbsolute(new FilePath($"{slnContext}/NETCore.Keycloak.sln")); + var slnPath = MakeAbsolute(new FilePath($"{slnContext}/NETCore.Keycloak.slnx")); // Define the dotCover cover command with XML report output var testCommand = $"cover --xml-report-output=dotCover.Output.xml -- test {slnPath} --configuration {configuration} -l:\"console;verbosity=normal\" --no-restore --no-build"; diff --git a/NETCore.Keycloak.Client.Tests/e2e_test.cake b/NETCore.Keycloak.Client.Tests/e2e_test.cake index 432b2f2..4cbb01b 100644 --- a/NETCore.Keycloak.Client.Tests/e2e_test.cake +++ b/NETCore.Keycloak.Client.Tests/e2e_test.cake @@ -20,7 +20,7 @@ Task("Test") .Does(() => { Information("Running unit tests..."); - DotNetTest($"{slnContext}/NETCore.Keycloak.sln", new DotNetTestSettings + DotNetTest($"{slnContext}/NETCore.Keycloak.slnx", new DotNetTestSettings { Configuration = configuration, NoBuild = true, diff --git a/NETCore.Keycloak.sln b/NETCore.Keycloak.sln deleted file mode 100644 index fc0adcc..0000000 --- a/NETCore.Keycloak.sln +++ /dev/null @@ -1,22 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NETCore.Keycloak.Client", "NETCore.Keycloak.Client\NETCore.Keycloak.Client.csproj", "{497CADC5-935D-4CC1-A60A-ED327FC872E8}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NETCore.Keycloak.Client.Tests", "NETCore.Keycloak.Client.Tests\NETCore.Keycloak.Client.Tests.csproj", "{DA20BD25-C24A-4507-8BC9-BCC44239DDDF}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {497CADC5-935D-4CC1-A60A-ED327FC872E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {497CADC5-935D-4CC1-A60A-ED327FC872E8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {497CADC5-935D-4CC1-A60A-ED327FC872E8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {497CADC5-935D-4CC1-A60A-ED327FC872E8}.Release|Any CPU.Build.0 = Release|Any CPU - {DA20BD25-C24A-4507-8BC9-BCC44239DDDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {DA20BD25-C24A-4507-8BC9-BCC44239DDDF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {DA20BD25-C24A-4507-8BC9-BCC44239DDDF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {DA20BD25-C24A-4507-8BC9-BCC44239DDDF}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/NETCore.Keycloak.slnx b/NETCore.Keycloak.slnx new file mode 100644 index 0000000..abdf473 --- /dev/null +++ b/NETCore.Keycloak.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/README.md b/README.md index 6055310..6634900 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ | .NET | 10.0 | | Dependencies | ASP.NET Core, Microsoft.Extensions.DependencyInjection, Newtonsoft.Json | +Use `NETCore.Keycloak.slnx` to open or build the solution with the .NET 10 SDK: + +```bash +dotnet build NETCore.Keycloak.slnx --configuration Release +``` + ## ✅ Version Compatibility | Keycloak Version | Support | diff --git a/build.cake b/build.cake index e2c7b96..00a749a 100644 --- a/build.cake +++ b/build.cake @@ -16,7 +16,7 @@ Task("Clean") .Does(() => { Information("Cleaning build output directories..."); - DotNetClean($"{slnContext}/NETCore.Keycloak.sln"); + DotNetClean($"{slnContext}/NETCore.Keycloak.slnx"); }); /// @@ -28,7 +28,7 @@ Task("Restore") .Does(() => { Information("Restoring NuGet packages..."); - DotNetRestore($"{slnContext}/NETCore.Keycloak.sln"); + DotNetRestore($"{slnContext}/NETCore.Keycloak.slnx"); }); /// @@ -40,7 +40,7 @@ Task("Build") .Does(() => { Information("Building the solution..."); - DotNetBuild($"{slnContext}/NETCore.Keycloak.sln", new DotNetBuildSettings + DotNetBuild($"{slnContext}/NETCore.Keycloak.slnx", new DotNetBuildSettings { Configuration = configuration }); @@ -64,4 +64,4 @@ Task("Default") }); // Execute the Default task -RunTarget("Default"); \ No newline at end of file +RunTarget("Default"); From 86f27d343de4b0c39408e80b66b91107f74f47fb Mon Sep 17 00:00:00 2001 From: jeff Date: Wed, 4 Nov 2026 11:27:37 +0800 Subject: [PATCH 4/7] =?UTF-8?q?Harden=20PR=20checks=20and=20Python=20tooli?= =?UTF-8?q?ng=20/=20=E5=8A=A0=E5=9B=BA=20PR=20=E6=A3=80=E6=9F=A5=E4=B8=8E?= =?UTF-8?q?=20Python=20=E5=B7=A5=E5=85=B7=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace unsafe XML report parsing with defusedxml and reject DTDs/entities. Reduce the Python environment to required tools, retain python-keycloak for fixture scripts, and hash-lock the full dependency graph. Pin Ansible collections including the Docker collection's transitive helper. Validate Python 3.12/3.14, audit every locked pin without vulnerability exceptions, and retain separate FOSSA library/test inventories. Confirm the library inventory includes its restored transitive NuGet dependency graph. Keep fork builds and tests independent of Sonar credentials. Update obsolete Actions, protect generated secrets, and poll PostgreSQL/Keycloak readiness. Restore package metadata to the upstream project. No production API behavior changes; fix only the Linux analyzer formatting in one existing test. 修复 XML 解析告警,精简并锁定测试依赖,补充 Python 3.14 验证。 分别审计生产包与测试工具;修复 fork CI、旧版 Action 及环境启动时序。 保护生成的测试凭据,恢复上游包元数据,保持客户端功能行为不变。 Validation / 验证: - Linux .NET 10 + Keycloak 26.8.1/PostgreSQL 16: 434 passed per Python 3.12 and 3.14 fixture profile; zero failures/skips; 178 methods executed. - FGAP v2: 31 passed, zero failures/skips. - XML security regressions: 5 passed per Python version. - Coverage: 99.65% lines, 83.72% branches. - Both Python audits: all 26 pins, zero known vulnerabilities. - NuGet direct/transitive audit: no known vulnerable packages reported. - All workflow definitions pass local actionlint. - External LLM review was not completed; local/manual review used instead. - Upstream Actions approval and FOSSA license-policy review remain external. --- .fossa-tests.yml | 11 + .fossa.yml | 33 +- .github/workflows/branch_naming_policy.yml | 4 +- .github/workflows/build_test_analyze.yml | 39 +- .github/workflows/deploy.yml | 5 +- .github/workflows/keycloak_2681.yml | 47 +- NETCore.Keycloak.Client.Tests/Makefile | 7 +- .../KcProtectedResourcePolicyProviderTests.cs | 4 +- NETCore.Keycloak.Client.Tests/README.md | 58 +- .../ansible/requirements.yml | 11 + .../ansible/tasks/common/generate_secrets.yml | 1 + .../ansible/tasks/database/setup.yml | 14 +- .../generate_services_environment.yml | 5 +- .../tasks/keycloak/clients/auth_client.yml | 3 +- .../ansible/tasks/keycloak/create_realm.yml | 2 + .../ansible/tasks/keycloak/setup.yml | 16 +- .../ansible/utils/extra/pre_flight.yml | 6 - .../requirements-report.in | 2 + .../requirements-report.txt | 6 + NETCore.Keycloak.Client.Tests/requirements.in | 8 + .../requirements.txt | 756 +++++++++++++++++- .../test_verify_test_results.py | 62 ++ .../verify_test_results.py | 7 +- .../NETCore.Keycloak.Client.csproj | 4 +- 24 files changed, 1025 insertions(+), 86 deletions(-) create mode 100644 .fossa-tests.yml create mode 100644 NETCore.Keycloak.Client.Tests/ansible/requirements.yml create mode 100644 NETCore.Keycloak.Client.Tests/requirements-report.in create mode 100644 NETCore.Keycloak.Client.Tests/requirements-report.txt create mode 100644 NETCore.Keycloak.Client.Tests/requirements.in create mode 100644 NETCore.Keycloak.Client.Tests/test_verify_test_results.py diff --git a/.fossa-tests.yml b/.fossa-tests.yml new file mode 100644 index 0000000..4fab777 --- /dev/null +++ b/.fossa-tests.yml @@ -0,0 +1,11 @@ +version: 3 + +# Use a separate FOSSA test-infrastructure project/revision with this config. +# Keep Ansible/PostgreSQL tooling visible for license review instead of +# ignoring its GPL/LGPL licenses in the published-library policy. +targets: + only: + - type: nuget + path: NETCore.Keycloak.Client.Tests/obj + - type: setuptools + path: NETCore.Keycloak.Client.Tests diff --git a/.fossa.yml b/.fossa.yml index c0210c7..ecd8751 100644 --- a/.fossa.yml +++ b/.fossa.yml @@ -1,18 +1,17 @@ -version: 1 +version: 3 -analysis: - # Paths to exclude from license/security analysis (relative to repo root) - ignorePaths: - - "NETCore.Keycloak.Client.Tests/**" - # Specific package locators to ignore (use exact locator shown by FOSSA) - ignorePackages: - - "pip+ansible$13.2.0" - - "pip+ansible-core$2.17.7" - - "nuget+Microsoft.NET.Test.Sdk$17.6.0" - -# Notes: -# - Commit and push this file and then trigger a new FOSSA scan so the server-side -# analysis picks up the repo-level exclusions. Local changes alone won't modify -# the existing project findings until FOSSA re-analyzes the repository. -# - If your organization manages FOSSA centrally, you may need to update project -# settings in the FOSSA UI instead of relying on this file. +# Run dotnet restore NETCore.Keycloak.slnx before CLI analysis. The NuGet +# analyzer reads obj/project.assets.json, so do not exclude that directory. +# Published library scope. Test infrastructure has its own inventory in +# .fossa-tests.yml and is also audited in the compatibility workflow. +# Select these scopes in FOSSA Quick Import if that integration does not +# consume CLI configuration; changing this file alone does not change policy. +paths: + only: + - NETCore.Keycloak.Client + exclude: + - "**/bin/**" +targets: + exclude: + # Generated package manifests duplicate the restored project graph. + - type: nuspec diff --git a/.github/workflows/branch_naming_policy.yml b/.github/workflows/branch_naming_policy.yml index 7214849..b62d1a0 100644 --- a/.github/workflows/branch_naming_policy.yml +++ b/.github/workflows/branch_naming_policy.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Extract branch name shell: bash @@ -30,4 +30,4 @@ jobs: PR_NUMBER: ${{ github.event.number }} GH_TOKEN: ${{ github.token }} run: | - gh pr close "$PR_NUMBER" -c "Pull request has been closed due the breach of branch naming policy" -d \ No newline at end of file + gh pr close "$PR_NUMBER" -c "Pull request has been closed due the breach of branch naming policy" -d diff --git a/.github/workflows/build_test_analyze.yml b/.github/workflows/build_test_analyze.yml index acebdae..f3bf099 100644 --- a/.github/workflows/build_test_analyze.yml +++ b/.github/workflows/build_test_analyze.yml @@ -12,6 +12,9 @@ on: - edited - synchronize +permissions: + contents: read + jobs: # Build test and analyze source code build_test_analyze: @@ -25,9 +28,9 @@ jobs: # Setup OpenJDK - name: Setup OpenJDK - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: - distribution: "adopt" + distribution: "temurin" java-version: ${{ matrix.java-version }} # Setup Python @@ -38,21 +41,43 @@ jobs: # Install the supported .NET SDK - name: Install .NET 10 SDK - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - # Install dependencies + - name: Build and test without repository secrets + run: | + cp LICENSE NETCore.Keycloak.Client/ + dotnet build NETCore.Keycloak.slnx --configuration Release + dotnet test NETCore.Keycloak.slnx --configuration Release --no-build \ + --filter 'TestCategory=CoverageUnit' --logger 'trx;LogFileName=unit.trx' \ + --results-directory NETCore.Keycloak.Client.Tests/obj/build-verification + + - name: Detect Sonar analysis availability + id: sonar + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + shell: bash + run: | + if [[ -n "$SONAR_TOKEN" ]]; then + echo 'available=true' >> "$GITHUB_OUTPUT" + else + echo 'available=false' >> "$GITHUB_OUTPUT" + echo '::notice::Sonar analysis requires an upstream secret. Build/unit tests and the independent compatibility workflow still run.' + fi + + # Sonar credentials are unavailable to fork pull requests. - name: Install dependencies + if: steps.sonar.outputs.available == 'true' run: | - sudo apt install -y make python3-rpm + sudo apt install -y make dotnet tool install --global dotnet-sonarscanner dotnet tool install --global Cake.Tool dotnet tool install --global JetBrains.dotCover.CommandLineTools # Build test and analyze the project - name: Build test and analyze the project - if: success() + if: steps.sonar.outputs.available == 'true' env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} VIRTUAL_ENV_DIR: keycloak.venv @@ -62,4 +87,4 @@ jobs: # Build, test and analyze project with keycloak version 26 cd NETCore.Keycloak.Client.Tests - dotnet cake build_test_analyse.cake --kc_major_version=26 --sonar_token=${SONAR_TOKEN} + dotnet cake build_test_analyse.cake --kc_major_version=26 --sonar_token="${SONAR_TOKEN}" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 406d9f6..9598832 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,9 @@ on: - closed workflow_dispatch: +permissions: + contents: read + jobs: # Deploy nuget package when a branch is labeled with `release` and the merge is done to master, or when triggered manually. Deploy: @@ -19,7 +22,7 @@ jobs: # Setup .NET SDK - name: Set up .NET SDK ${{ matrix.dotnet-version }} - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ matrix.dotnet-version }} diff --git a/.github/workflows/keycloak_2681.yml b/.github/workflows/keycloak_2681.yml index 14fe1b1..3c78e19 100644 --- a/.github/workflows/keycloak_2681.yml +++ b/.github/workflows/keycloak_2681.yml @@ -11,14 +11,49 @@ permissions: contents: read jobs: + dependencies: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + strategy: + matrix: + python: ['3.12', '3.14'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Audit locked Python test dependencies + run: | + python -m venv NETCore.Keycloak.Client.Tests/obj/audited-tools + NETCore.Keycloak.Client.Tests/obj/audited-tools/bin/python -m pip install \ + --require-hashes -r NETCore.Keycloak.Client.Tests/requirements.txt + python -m pip install pip-audit==2.10.1 + mkdir -p NETCore.Keycloak.Client.Tests/obj/dependency-audit + # Hashes were verified above. Manifest mode also audits bootstrap pins such as packaging. + pip-audit --strict --disable-pip --require-hashes --timeout 60 \ + -r NETCore.Keycloak.Client.Tests/requirements.txt \ + --format json --output NETCore.Keycloak.Client.Tests/obj/dependency-audit/pip-audit.json + - name: Upload dependency audit + if: always() + uses: actions/upload-artifact@v4 + with: + name: python-dependency-audit-${{ matrix.python }} + path: NETCore.Keycloak.Client.Tests/obj/dependency-audit/pip-audit.json unit: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' - uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x + - name: Verify report-parser security boundaries + run: | + python -m pip install --require-hashes -r NETCore.Keycloak.Client.Tests/requirements-report.txt + python -m unittest discover -s NETCore.Keycloak.Client.Tests -p test_verify_test_results.py - name: Run independent coverage unit tests run: | dotnet test NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj \ @@ -39,7 +74,13 @@ jobs: strategy: fail-fast: false matrix: - fgap: [v1, v2] + include: + - fgap: v1 + python: '3.12' + - fgap: v2 + python: '3.12' + - fgap: v1 + python: '3.14' defaults: run: working-directory: NETCore.Keycloak.Client.Tests @@ -51,7 +92,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: ${{ matrix.python }} - uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x @@ -76,7 +117,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: keycloak-26.8.1-fgap-${{ matrix.fgap }} + name: keycloak-26.8.1-fgap-${{ matrix.fgap }}-python-${{ matrix.python }} path: | NETCore.Keycloak.Client.Tests/obj/keycloak-26.8.1/**/*.trx NETCore.Keycloak.Client.Tests/obj/keycloak-26.8.1/**/coverage.cobertura.xml diff --git a/NETCore.Keycloak.Client.Tests/Makefile b/NETCore.Keycloak.Client.Tests/Makefile index fe615ad..6a1daeb 100644 --- a/NETCore.Keycloak.Client.Tests/Makefile +++ b/NETCore.Keycloak.Client.Tests/Makefile @@ -44,12 +44,9 @@ install_virtual_env: # Create a new virtual environment @python3 -m venv ${CONF_DIR_CONTEXT}/${VIRTUAL_ENV_DIR} # Activate the virtual environment and install dependencies - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && pip install -r requirements.txt + @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && python -m pip install --require-hashes -r requirements.txt # Install required Ansible collections - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-galaxy collection install community.general --force - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-galaxy collection install community.postgresql --force - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-galaxy collection install community.crypto --force - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-galaxy collection install community.docker --force + @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-galaxy collection install --no-cache -r ansible/requirements.yml # ----------------------------------- # Target: check_virtual_env diff --git a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcProtectedResourcePolicyProviderTests.cs b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcProtectedResourcePolicyProviderTests.cs index d3a8cc8..0aef9e2 100644 --- a/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcProtectedResourcePolicyProviderTests.cs +++ b/NETCore.Keycloak.Client.Tests/Modules/KcAuthorizationTests/KcProtectedResourcePolicyProviderTests.cs @@ -88,8 +88,10 @@ public async Task ShouldThrowsArgumentNullExceptionForNullOrEmptyPolicyName() // Act & Assert _ = await Assert.ThrowsExactlyAsync(async () => + { // ReSharper disable once AssignNullToNotNullAttribute - await _policyProvider.GetPolicyAsync(policyName).ConfigureAwait(false)).ConfigureAwait(false); + _ = await _policyProvider.GetPolicyAsync(policyName).ConfigureAwait(false); + }).ConfigureAwait(false); } /// diff --git a/NETCore.Keycloak.Client.Tests/README.md b/NETCore.Keycloak.Client.Tests/README.md index e6761ec..8879909 100644 --- a/NETCore.Keycloak.Client.Tests/README.md +++ b/NETCore.Keycloak.Client.Tests/README.md @@ -22,7 +22,7 @@ Key features of the test infrastructure: ### Prerequisites 1. **Python Environment**: - - Python 3.12 or higher (for the current Ansible requirements) + - Python 3.12 or 3.14 on Linux/WSL (the CI validation matrix) - pip (Python package installer) - virtualenv @@ -55,6 +55,52 @@ make install_virtual_env # - community.docker ``` +Only `ansible-core`, the four direct collections and Docker's inventory-filtering helper pinned +in `ansible/requirements.yml` are installed; +the full Ansible distribution and the unused python-jose/ecdsa/rsa stack are not required. +The fixture scripts still require `python-keycloak`. They use the same Python interpreter as Ansible. +`requirements.in` lists direct dependencies; `requirements.txt` locks the resolved Python 3.12/Linux +dependencies and their hashes. The same lock is also validated on Python 3.14. +Regenerate it from the repository root after reviewing an update: + +```bash +uv pip compile NETCore.Keycloak.Client.Tests/requirements.in --upgrade --python-version 3.12 \ + --python-platform x86_64-manylinux_2_28 --generate-hashes --index-url https://pypi.org/simple \ + --no-emit-index-url --output-file NETCore.Keycloak.Client.Tests/requirements.txt +python -m pip install --require-hashes -r NETCore.Keycloak.Client.Tests/requirements.txt +pip-audit --strict --disable-pip --require-hashes -r NETCore.Keycloak.Client.Tests/requirements.txt +``` + +CI first installs with hash verification, then audits every manifest pin without vulnerability +exceptions. Manifest mode avoids omitting bootstrap dependencies such as `packaging` during +temporary-environment resolution. The TRX/Cobertura verifier rejects DTDs, +entities, missing reports and skipped tests. Its independent security tests need only the small +`requirements-report.txt` lock: + +```bash +python -m pip install --require-hashes -r NETCore.Keycloak.Client.Tests/requirements-report.txt +python -m unittest discover -s NETCore.Keycloak.Client.Tests -p test_verify_test_results.py +``` + +The FOSSA CLI library profile `.fossa.yml` reads the restored library's `obj/project.assets.json`, +including transitive dependencies. Restore the solution before scanning; excluding all `obj` paths +would discard that dependency graph. `.fossa-tests.yml` separately inventories test NuGet packages +and Python tools. Validate both profiles locally without uploading: + +```bash +dotnet restore NETCore.Keycloak.slnx +fossa analyze --config .fossa.yml --output > library-dependencies.json +fossa analyze --config .fossa-tests.yml --output > test-dependencies.json +``` + +Both outputs must contain their expected targets and nonempty dependency graphs. A FOSSA maintainer +must apply equivalent scopes to OAuth/Quick Import projects if that integration does not consume CLI +configuration. Scope changes and a new scan do not approve licenses or change existing FOSSA policy. +Ansible-core/collections and the PostgreSQL driver retain their own GPL/LGPL or other notices; +they are test infrastructure and are not included in the published client NuGet package. Review +their use/distribution separately from the library's MIT license. Do not blanket-ignore their findings. +生产包与测试工具分别审计;测试依赖仍检查漏洞和许可证,不通过整体忽略来消除告警。 + ## Running Tests ### Independent coverage unit tests @@ -95,6 +141,16 @@ Local verification on 2026-11-03, .NET 10 Release and Keycloak 26.8.1: | Branch coverage | 70.59% | 83.72% | | Unexecuted source lines | 255 | 12 | +Revalidated on 2026-11-04 with Linux .NET 10 and Keycloak 26.8.1 backed by PostgreSQL 16: +fixtures provisioned with Python 3.12.16 and Python 3.14.10 each passed all 434 tests, with no +failures/skips and all 178 declared client methods executed. FGAP v2 passed its 31 applicable +regressions. Each Python version passed the five XML verifier security tests; the locked +26-package toolchain had no known matches in the independent OSV check. The production FOSSA +profile includes 7 direct and 25 total NuGet dependencies; the test profile retains both test +NuGet and Python inventories. Local Docker orchestration used the existing Windows Docker +host because that WSL distribution has no Docker integration. GitHub's Ubuntu 24.04 jobs still +require upstream approval and must pass before merge. + The 12 remaining lines are internal argument/null guards, the defensive null token-response return, repeated-disposal handling and the finalizer path. No reflection into private methods, forced garbage collection or coverage exclusions were added to inflate the result. diff --git a/NETCore.Keycloak.Client.Tests/ansible/requirements.yml b/NETCore.Keycloak.Client.Tests/ansible/requirements.yml new file mode 100644 index 0000000..a4cd9be --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/ansible/requirements.yml @@ -0,0 +1,11 @@ +collections: + - name: community.general + version: "11.4.2" + - name: community.postgresql + version: "4.2.0" + - name: community.crypto + version: "3.0.5" + - name: community.docker + version: "4.8.5" + - name: community.library_inventory_filtering_v1 + version: "1.1.5" diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/common/generate_secrets.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/common/generate_secrets.yml index e4773c5..148390b 100644 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/common/generate_secrets.yml +++ b/NETCore.Keycloak.Client.Tests/ansible/tasks/common/generate_secrets.yml @@ -1,5 +1,6 @@ # Generate stack secrets - name: Generate stack secrets + no_log: true block: # Define database credentials # Dynamically generates and sets database credentials including username, database name, and a secure password. diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/database/setup.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/database/setup.yml index bcfbe4c..3be5cc6 100644 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/database/setup.yml +++ b/NETCore.Keycloak.Client.Tests/ansible/tasks/database/setup.yml @@ -1,13 +1,15 @@ # Wait for database to start # Ensures that the database service is reachable before proceeding with further tasks. - name: Wait for database to start - wait_for: - host: 127.0.0.1 + community.postgresql.postgresql_ping: + login_user: "{{ db.username }}" + login_password: "{{ db.password }}" + login_host: 127.0.0.1 port: 5432 - timeout: 60 - state: started - delay: 5 - retries: 2 + register: postgres_readiness + until: postgres_readiness.is_available | default(false) + retries: 30 + delay: 2 # Create Keycloak database # Creates the Keycloak database if it does not already exist, ensuring it is ready for use. diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_services_environment.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_services_environment.yml index 75cefd1..5bfa898 100644 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_services_environment.yml +++ b/NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_services_environment.yml @@ -13,11 +13,12 @@ template: src: "templates/{{ item }}.j2" dest: "{{ containers_dir }}/{{ item }}" - mode: "0644" + mode: "0600" loop: - compose.yml - keycloak.env - postgres.env + no_log: true # Build JS policies JAR # Packages JavaScript authorization policies into a JAR for deployment to Keycloak providers. @@ -34,4 +35,4 @@ copy: src: "files/nginx.default.conf" dest: "{{ containers_dir }}/volumes/nginx/default.conf" - mode: "0644" \ No newline at end of file + mode: "0644" diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/auth_client.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/auth_client.yml index 74feec9..ec762e5 100644 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/auth_client.yml +++ b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/auth_client.yml @@ -244,9 +244,10 @@ # Delete auth_client client default authz - name: "Delete realm {{ realm }} auth_client client default authz" + no_log: true command: argv: - - "python3" + - "{{ ansible_playbook_python }}" - "scripts/delete_client_default_authz.py" - "{{ keycloak.auth_url }}" - "-a" diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/create_realm.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/create_realm.yml index de76596..8810f49 100644 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/create_realm.yml +++ b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/create_realm.yml @@ -90,6 +90,7 @@ # Obtain admin access token for enabling organizations # Retrieves an admin token from the master realm for API calls that are not supported by the Ansible Keycloak modules. - name: "Obtain admin access token for {{ realm }} realm configuration" + no_log: true uri: url: "{{ keycloak.auth_url }}/realms/{{ keycloak.auth_realm }}/protocol/openid-connect/token" method: POST @@ -107,6 +108,7 @@ # Enable organizations for the realm # Organizations are available starting from Keycloak 26 and must be enabled at the realm level. - name: "Enable organizations for {{ realm }} realm" + no_log: true uri: url: "{{ keycloak.auth_url }}/admin/realms/{{ realm }}" method: PUT diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/setup.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/setup.yml index 75c3e38..ba807da 100644 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/setup.yml +++ b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/setup.yml @@ -1,8 +1,13 @@ -# Wait for Keycloak to start -# Ensures that the Keycloak service is up and running before proceeding with further tasks. +# Wait for a working discovery endpoint rather than assuming startup takes 30 seconds. - name: Wait for keycloak to start - pause: - seconds: 30 + uri: + url: "{{ keycloak.auth_url }}/realms/{{ keycloak.auth_realm }}/.well-known/openid-configuration" + status_code: 200 + timeout: 5 + register: keycloak_discovery + until: keycloak_discovery.status | default(0) == 200 + retries: 60 + delay: 2 # Create realm # Includes the task file to create a Keycloak realm. @@ -44,9 +49,10 @@ # Assign roles to realm admin user # Assigns default roles to the admin user in the specified realm using a Python script. - name: "Assign realm {{ realm }} admin roles" + no_log: true command: argv: - - "python3" + - "{{ ansible_playbook_python }}" - "scripts/assign_admin_roles.py" - "{{ keycloak.auth_url }}" - "-a" diff --git a/NETCore.Keycloak.Client.Tests/ansible/utils/extra/pre_flight.yml b/NETCore.Keycloak.Client.Tests/ansible/utils/extra/pre_flight.yml index 166b7c9..a98903a 100644 --- a/NETCore.Keycloak.Client.Tests/ansible/utils/extra/pre_flight.yml +++ b/NETCore.Keycloak.Client.Tests/ansible/utils/extra/pre_flight.yml @@ -1,9 +1,3 @@ -# Gathering facts -- name: Gathering package fact - package_facts: - manager: auto - changed_when: false - # Check target platforms - name: "Check OS version and family" assert: diff --git a/NETCore.Keycloak.Client.Tests/requirements-report.in b/NETCore.Keycloak.Client.Tests/requirements-report.in new file mode 100644 index 0000000..002dc11 --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/requirements-report.in @@ -0,0 +1,2 @@ +# The report verifier and its security regression tests need no Ansible stack. +defusedxml==0.7.1 diff --git a/NETCore.Keycloak.Client.Tests/requirements-report.txt b/NETCore.Keycloak.Client.Tests/requirements-report.txt new file mode 100644 index 0000000..cc19ec8 --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/requirements-report.txt @@ -0,0 +1,6 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile NETCore.Keycloak.Client.Tests/requirements-report.in --python-version 3.12 --generate-hashes --no-emit-index-url --output-file NETCore.Keycloak.Client.Tests/requirements-report.txt +defusedxml==0.7.1 \ + --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \ + --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 + # via -r NETCore.Keycloak.Client.Tests/requirements-report.in diff --git a/NETCore.Keycloak.Client.Tests/requirements.in b/NETCore.Keycloak.Client.Tests/requirements.in new file mode 100644 index 0000000..f77f094 --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/requirements.in @@ -0,0 +1,8 @@ +# Direct dependencies for the Python 3.12 Linux integration-test environment. +# Collections are pinned separately in ansible/requirements.yml. +ansible-core==2.20.11 +cryptography==50.0.2 +defusedxml==0.7.1 +psycopg2-binary==2.9.13 +python-keycloak==7.0.2 +requests==2.34.2 diff --git a/NETCore.Keycloak.Client.Tests/requirements.txt b/NETCore.Keycloak.Client.Tests/requirements.txt index d4dc2e6..3c5e256 100644 --- a/NETCore.Keycloak.Client.Tests/requirements.txt +++ b/NETCore.Keycloak.Client.Tests/requirements.txt @@ -1,24 +1,732 @@ -ansible>=13.2.0 -ansible-core>=2.17.7 -certifi==2024.12.14 -cffi==1.17.1 -charset-normalizer==3.4.1 -cryptography==44.0.1 -deprecation==2.1.0 -ecdsa==0.19.0 -idna==3.10 -importlib-resources==5.0.7 -Jinja2==3.1.6 -MarkupSafe==3.0.2 -packaging==24.2 -pycparser==2.22 -python-jose== 3.4.0 -python-keycloak==7.0.2 -PyYAML==6.0.2 -requests==2.32.4 -requests-toolbelt==1.0.0 -resolvelib==1.0.1 -rsa==4.9 -six==1.17.0 -urllib3==2.6.3 -psycopg2-binary==2.9.10 +# This file was autogenerated by uv via the following command: +# uv pip compile NETCore.Keycloak.Client.Tests/requirements.in --python-version 3.12 --python-platform x86_64-manylinux_2_28 --generate-hashes --no-emit-index-url --output-file NETCore.Keycloak.Client.Tests/requirements.txt +aiofiles==25.1.0 \ + --hash=sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2 \ + --hash=sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695 + # via python-keycloak +ansible-core==2.20.11 \ + --hash=sha256:a136e1b04c705e44d14507058008206eac3bae9179fa8499c36b0048ef1a2b33 \ + --hash=sha256:f1738b566514c36e4cc635d10e4d728555d55867a9bdbfed709f05aa6bab4d82 + # via -r NETCore.Keycloak.Client.Tests/requirements.in +anyio==4.16.0 \ + --hash=sha256:59bb03251907983d1603d27c3087ddb1e93299ea2163adcdba37899a4fb12ffb \ + --hash=sha256:c66ae634e0b8228396b801741beb3294326d4b6102f7bba4041c0dfb58c29026 + # via httpx +certifi==2026.10.23 \ + --hash=sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd \ + --hash=sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1 + # via + # httpcore + # httpx + # requests +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via cryptography +charset-normalizer==3.5.2 \ + --hash=sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e \ + --hash=sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf \ + --hash=sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5 \ + --hash=sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56 \ + --hash=sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26 \ + --hash=sha256:0891b9d3903c5571c03771ca669a4b0ec5618ca722a5c957d3d29cd4e5062848 \ + --hash=sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718 \ + --hash=sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93 \ + --hash=sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640 \ + --hash=sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3 \ + --hash=sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875 \ + --hash=sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e \ + --hash=sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275 \ + --hash=sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204 \ + --hash=sha256:15bb4005af6320d259dc7593ca84a38d7fe06a421dbcf7b910ae23979101e787 \ + --hash=sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234 \ + --hash=sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3 \ + --hash=sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98 \ + --hash=sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3 \ + --hash=sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187 \ + --hash=sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d \ + --hash=sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f \ + --hash=sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7 \ + --hash=sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011 \ + --hash=sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f \ + --hash=sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869 \ + --hash=sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1 \ + --hash=sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d \ + --hash=sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847 \ + --hash=sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320 \ + --hash=sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9 \ + --hash=sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93 \ + --hash=sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd \ + --hash=sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00 \ + --hash=sha256:2cc961b171b3f3440f410489ab3573e86aea8736134ebbb40ea1338b7f0831bc \ + --hash=sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0 \ + --hash=sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09 \ + --hash=sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac \ + --hash=sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621 \ + --hash=sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c \ + --hash=sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8 \ + --hash=sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a \ + --hash=sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51 \ + --hash=sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0 \ + --hash=sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef \ + --hash=sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa \ + --hash=sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6 \ + --hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649 \ + --hash=sha256:3ddacd27458c45bdacd6bd6db644bfb730efbf9e830310186e3045c9c5be8fb2 \ + --hash=sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229 \ + --hash=sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e \ + --hash=sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd \ + --hash=sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115 \ + --hash=sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9 \ + --hash=sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c \ + --hash=sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c \ + --hash=sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab \ + --hash=sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253 \ + --hash=sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995 \ + --hash=sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438 \ + --hash=sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0 \ + --hash=sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be \ + --hash=sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b \ + --hash=sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7 \ + --hash=sha256:55ea99acb17b9325618de155a0cd6a2e8f5d10be008113e1d433bbb58db543b2 \ + --hash=sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a \ + --hash=sha256:588461c2e8384d309bd63e5826019b6977bc66d629b99ac8737bb795d7b2cb5a \ + --hash=sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a \ + --hash=sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c \ + --hash=sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5 \ + --hash=sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37 \ + --hash=sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e \ + --hash=sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4 \ + --hash=sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800 \ + --hash=sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055 \ + --hash=sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e \ + --hash=sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5 \ + --hash=sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c \ + --hash=sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b \ + --hash=sha256:68eb192d85ab8e5f6ec69c2bc6ac0179fbf04a5ac1569d12fbef74883fe102d0 \ + --hash=sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80 \ + --hash=sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a \ + --hash=sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4 \ + --hash=sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2 \ + --hash=sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58 \ + --hash=sha256:75a3ceed0724d625d64b86ca20aba182e4df462e04c2414fc941c0f523f06aac \ + --hash=sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc \ + --hash=sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639 \ + --hash=sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf \ + --hash=sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d \ + --hash=sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f \ + --hash=sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c \ + --hash=sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc \ + --hash=sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4 \ + --hash=sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253 \ + --hash=sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade \ + --hash=sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858 \ + --hash=sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26 \ + --hash=sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96 \ + --hash=sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8 \ + --hash=sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249 \ + --hash=sha256:8b2bfab86aa71ae13aa41a6a26aab338e0db2b8bc75434b05aea89e011ff35a4 \ + --hash=sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13 \ + --hash=sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1 \ + --hash=sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03 \ + --hash=sha256:93223adc95033dd47133a46ccfc316a0139176fd79085762e27202ec56018f03 \ + --hash=sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e \ + --hash=sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364 \ + --hash=sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4 \ + --hash=sha256:9bde855991b7e362c146535e3136a50bfaffc0487d38b33ca7e5edefc6e23849 \ + --hash=sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0 \ + --hash=sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a \ + --hash=sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036 \ + --hash=sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3 \ + --hash=sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21 \ + --hash=sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3 \ + --hash=sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e \ + --hash=sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413 \ + --hash=sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21 \ + --hash=sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346 \ + --hash=sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429 \ + --hash=sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685 \ + --hash=sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45 \ + --hash=sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f \ + --hash=sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c \ + --hash=sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d \ + --hash=sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad \ + --hash=sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400 \ + --hash=sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb \ + --hash=sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c \ + --hash=sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc \ + --hash=sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c \ + --hash=sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74 \ + --hash=sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf \ + --hash=sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604 \ + --hash=sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f \ + --hash=sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105 \ + --hash=sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a \ + --hash=sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d \ + --hash=sha256:d4a7319f304a774bed22115bc891618e45f85065ab44ea6acd07d274e750519a \ + --hash=sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1 \ + --hash=sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5 \ + --hash=sha256:d913de495d90407cd859d263bee2e5d1a4ed3eb6573c04e70d9ec619a7cbed7f \ + --hash=sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e \ + --hash=sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709 \ + --hash=sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874 \ + --hash=sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5 \ + --hash=sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc \ + --hash=sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95 \ + --hash=sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd \ + --hash=sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0 \ + --hash=sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d \ + --hash=sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3 \ + --hash=sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c \ + --hash=sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3 \ + --hash=sha256:e80e6c2f55656b4824d72065abb4ddd6a525c74bd78a0aab5d9fc2cf4fb5af50 \ + --hash=sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491 \ + --hash=sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5 \ + --hash=sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5 \ + --hash=sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655 \ + --hash=sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288 \ + --hash=sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd \ + --hash=sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084 \ + --hash=sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d \ + --hash=sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4 \ + --hash=sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915 \ + --hash=sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1 \ + --hash=sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd \ + --hash=sha256:fc14a032f813bf5fe624d991960ea83e9715adc27e4c1830a2361eb1d02ac341 \ + --hash=sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424 \ + --hash=sha256:fd1fbe0f116b6e55da77aca2c6ddcddcfac2186cbf78bdebf40fc156efca389d \ + --hash=sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f + # via requests +cryptography==50.0.2 \ + --hash=sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602 \ + --hash=sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2 \ + --hash=sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047 \ + --hash=sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c \ + --hash=sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42 \ + --hash=sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18 \ + --hash=sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51 \ + --hash=sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81 \ + --hash=sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856 \ + --hash=sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2 \ + --hash=sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de \ + --hash=sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7 \ + --hash=sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd \ + --hash=sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2 \ + --hash=sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be \ + --hash=sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45 \ + --hash=sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0 \ + --hash=sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e \ + --hash=sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c \ + --hash=sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5 \ + --hash=sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452 \ + --hash=sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48 \ + --hash=sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05 \ + --hash=sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1 \ + --hash=sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93 \ + --hash=sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04 \ + --hash=sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e \ + --hash=sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67 \ + --hash=sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7 \ + --hash=sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107 \ + --hash=sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079 \ + --hash=sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134 \ + --hash=sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227 \ + --hash=sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1 \ + --hash=sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539 \ + --hash=sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e \ + --hash=sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d \ + --hash=sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c \ + --hash=sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd \ + --hash=sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020 \ + --hash=sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd \ + --hash=sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94 \ + --hash=sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a \ + --hash=sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408 \ + --hash=sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37 \ + --hash=sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e \ + --hash=sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454 \ + --hash=sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c \ + --hash=sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc \ + --hash=sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37 \ + --hash=sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767 \ + --hash=sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a \ + --hash=sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5 \ + --hash=sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc \ + --hash=sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67 \ + --hash=sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8 \ + --hash=sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480 \ + --hash=sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb \ + --hash=sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b + # via + # -r NETCore.Keycloak.Client.Tests/requirements.in + # ansible-core + # jwcrypto +defusedxml==0.7.1 \ + --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \ + --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 + # via -r NETCore.Keycloak.Client.Tests/requirements.in +deprecation==2.1.0 \ + --hash=sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff \ + --hash=sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a + # via python-keycloak +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via python-keycloak +idna==3.20 \ + --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ + --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c + # via + # anyio + # httpx + # requests +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via ansible-core +jwcrypto==1.6.1 \ + --hash=sha256:77e856818e514cb3d64a1043b6885a7d52f5cc37773f5e944de02103c8d3a54f \ + --hash=sha256:a1e1570da5c2e35dbcd375ec1d2891a24de8eb35ee2c1fe8e91978efb72cfe90 + # via python-keycloak +markupsafe==3.0.4 \ + --hash=sha256:007e1ffd9bf65bb6ee96df7b258fc632a4868dd5566037986c64781f35a36e98 \ + --hash=sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002 \ + --hash=sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b \ + --hash=sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653 \ + --hash=sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c \ + --hash=sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e \ + --hash=sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc \ + --hash=sha256:0764a13d34cae40db7bbf3a09b7e9b491bf4603e20b263a7a9d6b8e324975d0a \ + --hash=sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92 \ + --hash=sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f \ + --hash=sha256:0cee7cb0f9a1b6892ea482237d9403b3d1b4603aee057d0ff01f0fac2d019a97 \ + --hash=sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4 \ + --hash=sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7 \ + --hash=sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691 \ + --hash=sha256:14bd2d845d62ab678eaf81da89d7b621b51756c72346745c1a594c09d49207a2 \ + --hash=sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc \ + --hash=sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde \ + --hash=sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99 \ + --hash=sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9 \ + --hash=sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df \ + --hash=sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5 \ + --hash=sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17 \ + --hash=sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8 \ + --hash=sha256:2a6ef68ae94aed8721934072b27a3b654ea2100b97e4ab864cf1489c90926fbc \ + --hash=sha256:2b2b1e18af909b448bb3cf9e3433366f7a8726271fc214e8b10e0f62a78c724b \ + --hash=sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea \ + --hash=sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248 \ + --hash=sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741 \ + --hash=sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5 \ + --hash=sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6 \ + --hash=sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7 \ + --hash=sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1 \ + --hash=sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67 \ + --hash=sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f \ + --hash=sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9 \ + --hash=sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c \ + --hash=sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc \ + --hash=sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba \ + --hash=sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17 \ + --hash=sha256:3d23795802fc8bd72534836d64489bbf0f67c088959091bdb22e10735a5107bf \ + --hash=sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6 \ + --hash=sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2 \ + --hash=sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163 \ + --hash=sha256:4a540e2d3192792fc84eced57bef37851ccb2b41f73291bb17408eea77bcd278 \ + --hash=sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d \ + --hash=sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b \ + --hash=sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634 \ + --hash=sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38 \ + --hash=sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed \ + --hash=sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c \ + --hash=sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148 \ + --hash=sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a \ + --hash=sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7 \ + --hash=sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f \ + --hash=sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811 \ + --hash=sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e \ + --hash=sha256:57f9947a7e57a081c1e3e0a2dd0d2dcf290a4531450e6f611e30084c222a7295 \ + --hash=sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2 \ + --hash=sha256:5c22873ad1f0532ba40fa1727f3c0fc1bbbaab6d373d4cbe3f0dc74b2e2521c7 \ + --hash=sha256:5e8b3d0b18fd623afa12ecb2ce8d8becef69f9b5440c6330c7972200e0bb84b0 \ + --hash=sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6 \ + --hash=sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed \ + --hash=sha256:6669c1bf34080161ce49c589cc512ef24d4c704ac9d2b2d3667f519c60418378 \ + --hash=sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0 \ + --hash=sha256:6768d67d1bce64270e0fdc2e69309d68b9b18ae56ddf6c711d168e9d051c2cac \ + --hash=sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b \ + --hash=sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96 \ + --hash=sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59 \ + --hash=sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808 \ + --hash=sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2 \ + --hash=sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb \ + --hash=sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65 \ + --hash=sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72 \ + --hash=sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8 \ + --hash=sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e \ + --hash=sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91 \ + --hash=sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a \ + --hash=sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2 \ + --hash=sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e \ + --hash=sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707 \ + --hash=sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21 \ + --hash=sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef \ + --hash=sha256:8698d70a8081ee8c090dbb394768b5789a1da8b131b5499f89d071dd3cfaf6be \ + --hash=sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453 \ + --hash=sha256:88d59b473bfb03259722600839af9bbd7fa13a2eb514beefeedb95997882f69a \ + --hash=sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6 \ + --hash=sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977 \ + --hash=sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978 \ + --hash=sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581 \ + --hash=sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692 \ + --hash=sha256:9240187afb63d2f9ddc3e032c670356fe941f6e20662ea168a5dc3f1f317e1b3 \ + --hash=sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369 \ + --hash=sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a \ + --hash=sha256:9388003072b95f2f1e3fd908604194d653ba21330d811961a78b7da1a77e9e36 \ + --hash=sha256:9438a2648b2195980cb2dd8e53ed7b8df91319e2d0b70ae61a9e1d1bc8d3bec9 \ + --hash=sha256:94e4c421742086aeee4c32a506eec8859d7634aad943f7e6aacf70f813478768 \ + --hash=sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916 \ + --hash=sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b \ + --hash=sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f \ + --hash=sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346 \ + --hash=sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c \ + --hash=sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464 \ + --hash=sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9 \ + --hash=sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee \ + --hash=sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300 \ + --hash=sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6 \ + --hash=sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d \ + --hash=sha256:ac0c7c9f1609b0c4c114feb1d7a3409564c7fb77e360bed9e97e5d25dfeaf868 \ + --hash=sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46 \ + --hash=sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97 \ + --hash=sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733 \ + --hash=sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe \ + --hash=sha256:b61687d0828e72bf5cda24a2690188f37170bd31c9359ac97e4e66569f120a16 \ + --hash=sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429 \ + --hash=sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39 \ + --hash=sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894 \ + --hash=sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c \ + --hash=sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c \ + --hash=sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169 \ + --hash=sha256:bf053da3c97a4bc5ecfbb218cdd2983febd91c617be8367d139882aa11e490aa \ + --hash=sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77 \ + --hash=sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe \ + --hash=sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad \ + --hash=sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85 \ + --hash=sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e \ + --hash=sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34 \ + --hash=sha256:cf63c214fe879a65e69a386f915e36104fc84254ab141240f8854602d8e0be2a \ + --hash=sha256:d1aca03ede943eb80ab3d63bb082c84b7aab85ea83bd0fd0c200260945fb49d9 \ + --hash=sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c \ + --hash=sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749 \ + --hash=sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214 \ + --hash=sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932 \ + --hash=sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494 \ + --hash=sha256:dd8ea6ebee7aedbf7c749fa80521d9ccf1ba473e0d1e14805caafbaad281c889 \ + --hash=sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1 \ + --hash=sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0 \ + --hash=sha256:dff05cb7016dff1e9fd68f4122c127b65dfc59de5306cfb7ad92f956f230bee2 \ + --hash=sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786 \ + --hash=sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78 \ + --hash=sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e \ + --hash=sha256:e841068dc0be4cb6dfb5c890eb88cbdcff2f4a332393c7ec94e8e618bd32c1a8 \ + --hash=sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289 \ + --hash=sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c \ + --hash=sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe \ + --hash=sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237 \ + --hash=sha256:f291bcf42ae98eb5107edb162c3c998b4a89648fd8e99ed4cbd12705292788cd \ + --hash=sha256:f61efe1d2fe0de16158a5fe1d1cf3c14bdb6aecd54d8938fd26512c525c1f624 \ + --hash=sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19 \ + --hash=sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977 \ + --hash=sha256:fd9f8797427910198f95bced71ddfed61130d7e349213bfb8466c9c99e2c46a8 \ + --hash=sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3 + # via jinja2 +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via + # ansible-core + # deprecation +psycopg2-binary==2.9.13 \ + --hash=sha256:0405dd4d97720e7ab177aa02e493f524907c4cb3c445ac173e2627948d3d0528 \ + --hash=sha256:0463c00f946517f3e69192a59e6601e023ff9de45ad0a875eda3d6b1bebeb7ce \ + --hash=sha256:07b7bd9f410650c34c3532162cc329f112368d78a3fc8668cb1ea9df61bc11bf \ + --hash=sha256:086659ab083119f7ee87a779e31b94211cf162b708fc9a6bec771f75c73ac3e6 \ + --hash=sha256:08d3b81a6a91775c937abf97d4c58fc9142e8e35fb91c387d24f81d15c98e6cf \ + --hash=sha256:0a6444ac48e2c04f691c2ddd542b38ba30c89463a2d446b3d74ec7d8fc90c964 \ + --hash=sha256:0ebcf3c4266a695df9d0ef51296155f60c86ac51cf82f0d0dd2e827255a891c5 \ + --hash=sha256:13d955f6054a705a19554364fe9888d0a6e8b0746dc7ebc08a447c7b4fd4145c \ + --hash=sha256:1752b9821f1377404d65ac43af03d59a1eccc57fb2c1eb8305f9a3fe8eb7a8ba \ + --hash=sha256:190c18b97d9ef72f2e88c451b6588af90d6bd7bf54cb94b963280dc86a2c7076 \ + --hash=sha256:1f4c7bdbafdf9dc018efbc29213b73f8308332888ba76a4cf503f560bfd21705 \ + --hash=sha256:202dedd5cadb3e5dfd4d0415ab2fc5d5b44f4208de5308938e3e74ae222b638e \ + --hash=sha256:215777c62ce81c3b487cefdb6a41969944eb982309f91349ff3ca0323d6f17ed \ + --hash=sha256:27e539b4cafd5e03dcd32921db1b12dd72fe549dd06bae6d4d2a5b5838465f24 \ + --hash=sha256:28eb30bf4a52c1117406f45771038faa96f882fdeeeb0ce43b960a1dbc6c1fd2 \ + --hash=sha256:2bf9f97a6df69a5d89d054b8cf5257a0916096c479800715fbfe7974dbcb3a26 \ + --hash=sha256:2ca263643ae37998ae04d18e431df34d0d61f12b47640dab585f14b6dbe00798 \ + --hash=sha256:31db6cba66df5231dfd91d9f69188bec3fe6c8baae384e93a0ce792067ee2d98 \ + --hash=sha256:32cd049095135d2b69e824aea9056745a4aaaa9115a9febbc65584793665d0d0 \ + --hash=sha256:33a6d3c47f9655b481b2cdc1b4bf71c235e054e55663d3066036b6ce5fbe5165 \ + --hash=sha256:376ebf7d8aee4b7386b2bac31fdc27911e7e57cd0a88f1e038b8b149398ac008 \ + --hash=sha256:38397def2d794ffde9db80f63d6820253e61b17483112652a318355f51a56f50 \ + --hash=sha256:3aea95340825f5ff236e7b40f0b5602c2c77a1e95943f71fae34909834043d29 \ + --hash=sha256:3dc3372b3731b3ef23407fe06b94f640ef87a2bda242fa386033d5589c87514a \ + --hash=sha256:3e60b06ec7f9dc3e5f1106d12706514b6d6b92c3dc438fcdf4e43e65cc660d1b \ + --hash=sha256:3f699a5225094a5c61402984e2fc1eca20e940223e76767c88189efb0c313f69 \ + --hash=sha256:41c2eb569ebd0e1b02d30d361a46932923b193fe1b5e641fb4d547c75e218955 \ + --hash=sha256:4c0214c7da18a28d108aa7108c8a3cca8035c7911ec97ef9ec0827569c9a2720 \ + --hash=sha256:4d66bfd44a46eb88cff0287929a4193fb45166b6c1f84bb1b233cc17ece0813c \ + --hash=sha256:4e55357d1943673d491bbabb171c891704fc6a22441fea539e05a5c27a79ea3c \ + --hash=sha256:4ff0f575cbb14f30445858dcfdd751e043486f5290915df78a9818bc74042eff \ + --hash=sha256:5085f7ff7b1e890f279577cedeb8c628957869a340fa34a39f7f406500b3c916 \ + --hash=sha256:541a487a9ccd72b5e38f37f27b0ce78cb7eb3e336e7b5277d45463010c03a7a8 \ + --hash=sha256:562fe2a43b30e781848dce63d9080c15414c777c96df348c4342558338cc7bf3 \ + --hash=sha256:5d89e064bb12b40cad696cf4975e6da86f8c60f14cd06cb6c1bc0a7f5d01761f \ + --hash=sha256:5f04ae99c9fbb94c3197ec88599ed7db921f6adcddfe83687a74c7ead4037c22 \ + --hash=sha256:691da68ae5dd7c3ac77514357d35ece7b1ba8b5f3e6c92735198aa6159c355c8 \ + --hash=sha256:6e696297891b56ff0115f0665de6ad774e1e301e4f60745b8d5024001ae7c2f6 \ + --hash=sha256:6ede8595767e19d30a7e8a84a7d47bfde6176d45d194fed08dbb68d1584a780b \ + --hash=sha256:70d091f5c3a6177fac50c0da20181ce0e0c053f1e43c872d5f75bd6d9429c020 \ + --hash=sha256:7e2405196a8cfe6cd3e54172a54452dcf85c241eaf2e9dde7190d7469f7f5ef7 \ + --hash=sha256:81404c37e0344ebcf10aac127d33d35137e5dbab1daf9f3deee46188fd5879c2 \ + --hash=sha256:81682c227cc1849c4a6adf7b85274229073bb4c9d6ad5697222c695dcea5a8a7 \ + --hash=sha256:8cb734989420c18ca1b71a82da880e11988f5ff3fcdaadd669161de3e98794ac \ + --hash=sha256:930e7e58b33a4f9c39e7532d7a40147925cf3372baed4229cbebe0cf3ba9ce6b \ + --hash=sha256:aa37089795bd9701576edc2eb5849ce77a439eda9dfdfa47857449332cfa5292 \ + --hash=sha256:b6ae51708201f501a171b02419d0c30878a743c369c9054eb1289f0f8d5979e2 \ + --hash=sha256:c00ebe9a2f31151aade0db233dc1446513a95e92c39ce055ee097af0ae86be1c \ + --hash=sha256:c24c98fe1a113db287dfb1958771eafca97b7db812f23b7897c2a12b6b904c22 \ + --hash=sha256:c519e406287085f43aa0d3061936edf1ba51286093532f215315c6ab8ba92c3b \ + --hash=sha256:d19aec88857d2a52f99eefcefdbbb45921fb2f777bee5186a355a23d9cf8a0b9 \ + --hash=sha256:d2fc9342aad969b9a28490a4c3eaba94b35beb2d26e9a39b31d1430378aa71b2 \ + --hash=sha256:d79530b4c1af657d5620a1d21b8e39f2996aa06821d5564d05b22d6b8cd413d0 \ + --hash=sha256:db31cf7f617a51625f1473d8a66fc35dac159af8b28e80bc014ed3ee994a9fbf \ + --hash=sha256:dddfe650e7dda464d676c27fbedb5061f1ad05e1604627f54c770d7f799d36e9 \ + --hash=sha256:dde942b46ce20f6c4464cdf551f3293207f803f4e4354454eb1f5599c3eb1fa1 \ + --hash=sha256:dff5c70ed9789ccb0d97ff4a7da51dc523a255c4ec95df188fa5d44adcae4ea8 \ + --hash=sha256:e324ecf60f952d21dd11413b8bbed0951bbd99579a06fd06f28bfc37737cd373 \ + --hash=sha256:e3861eba31f8ea8663fd876166b032fd89179e42aa63764d6feb281f13f9eb60 \ + --hash=sha256:f04ada42bcd537adbaf8b7f3140237a204e452a88d0c1831cfce69f7d2e59f4e \ + --hash=sha256:f124954a32640dfb5c000d33028f48053930d7ff226bc74cde5fb316f9c6fcb6 \ + --hash=sha256:f28b5f2fa8154d0d97e97a664136f58d1639ca008d45d6e09e69fff24826abee \ + --hash=sha256:f3088eb80f58ed933c62d87128741d31e786edc862e23266d3c286763d646de0 \ + --hash=sha256:f47f23db2d70db39cfb714b64fd5df76595b51b2ec0a669710a78f2dceb0c3f8 \ + --hash=sha256:f4cdfe41149dcc5583a3b7a2f0ad433f75bb3afd1c7a7332e63df89b05e34666 \ + --hash=sha256:f818161d2302b3b3e9c75d5a1d0a5c5679e92e45cfec6432b9d5432dde5ff1f1 \ + --hash=sha256:feb7b1856f6ca805cc0e08739858f6cdfed8ce903390126af30343c62899a389 + # via -r NETCore.Keycloak.Client.Tests/requirements.in +pycparser==3.12 \ + --hash=sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e \ + --hash=sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6 + # via cffi +python-keycloak==7.0.2 \ + --hash=sha256:0e90612f0ea57822376085426086fa636a874678b03e9676d06fb0da0bb2fe2a \ + --hash=sha256:37923f9f0c8dab67a3f3b92b5a78b639fd2461548918a8d9774bd0532fb09173 + # via -r NETCore.Keycloak.Client.Tests/requirements.in +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via ansible-core +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed + # via + # -r NETCore.Keycloak.Client.Tests/requirements.in + # python-keycloak + # requests-toolbelt +requests-toolbelt==1.0.0 \ + --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \ + --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06 + # via python-keycloak +resolvelib==1.2.1 \ + --hash=sha256:7d08a2022f6e16ce405d60b68c390f054efcfd0477d4b9bd019cc941c28fad1c \ + --hash=sha256:fb06b66c8da04172d9e72a21d7d06186d8919e32ae5ab5cdf5b9d920be805ac2 + # via ansible-core +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # anyio + # jwcrypto +urllib3==2.8.0 \ + --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \ + --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63 + # via requests diff --git a/NETCore.Keycloak.Client.Tests/test_verify_test_results.py b/NETCore.Keycloak.Client.Tests/test_verify_test_results.py new file mode 100644 index 0000000..25ec5fc --- /dev/null +++ b/NETCore.Keycloak.Client.Tests/test_verify_test_results.py @@ -0,0 +1,62 @@ +"""Security regressions for the TRX/Cobertura acceptance gate.""" + +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +from defusedxml.common import DTDForbidden + +import verify_test_results + +PASSED_TRX = """ + + +""" +ENTITY_XML = """]> +&payload;""" + + +class ReportVerifierTests(unittest.TestCase): + """Exercise the CI entry point with valid and hostile report files.""" + + def run_verifier(self, files, *arguments): + with tempfile.TemporaryDirectory() as directory: + for name, contents in files.items(): + Path(directory, name).write_text(contents, encoding="utf-8") + output = StringIO() + with patch.object(sys, "argv", ["verify_test_results.py", directory, *arguments]): + with redirect_stdout(output): + verify_test_results.main() + return output.getvalue() + + def test_accepts_complete_passed_run(self): + output = self.run_verifier({"results.trx": PASSED_TRX}) + self.assertIn("Verified 1 passed tests", output) + + def test_rejects_missing_reports(self): + with self.assertRaisesRegex(SystemExit, "No TRX reports found"): + self.run_verifier({}) + + def test_rejects_skipped_test(self): + report = PASSED_TRX.replace('outcome="Passed"', 'outcome="NotExecuted"') + with self.assertRaisesRegex(SystemExit, "Failed, skipped, or aborted tests"): + self.run_verifier({"results.trx": report}) + + def test_rejects_trx_doctype_and_entities(self): + with self.assertRaises(DTDForbidden): + self.run_verifier({"results.trx": ENTITY_XML}) + + def test_rejects_coverage_doctype_and_entities(self): + with self.assertRaises(DTDForbidden): + self.run_verifier( + {"results.trx": PASSED_TRX, "coverage.cobertura.xml": ENTITY_XML}, + "--require-http-method-coverage", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/NETCore.Keycloak.Client.Tests/verify_test_results.py b/NETCore.Keycloak.Client.Tests/verify_test_results.py index 3056aeb..9c18c0b 100644 --- a/NETCore.Keycloak.Client.Tests/verify_test_results.py +++ b/NETCore.Keycloak.Client.Tests/verify_test_results.py @@ -2,9 +2,10 @@ import argparse import re -import xml.etree.ElementTree as ET from pathlib import Path +from defusedxml import ElementTree as ET + def main(): parser = argparse.ArgumentParser(description=__doc__) @@ -16,7 +17,7 @@ def main(): raise SystemExit("No TRX reports found.") total = 0 for report in reports: - root = ET.parse(report).getroot() + root = ET.parse(report, forbid_dtd=True).getroot() counters = root.find("{*}ResultSummary/{*}Counters") results = root.findall("{*}Results/{*}UnitTestResult") if counters is None: @@ -46,7 +47,7 @@ def verify_http_method_coverage(directory): modules = {implementation for implementation, _ in expected} covered = set() for report in directory.rglob("coverage.cobertura.xml"): - root = ET.parse(report).getroot() + root = ET.parse(report, forbid_dtd=True).getroot() for implementation in root.findall(".//class"): name = implementation.get("name", "") module = name.split("/", 1)[0] diff --git a/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj b/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj index dc793d2..0f5b16f 100644 --- a/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj +++ b/NETCore.Keycloak.Client/NETCore.Keycloak.Client.csproj @@ -10,10 +10,10 @@ keycloak;oauth2;authentication;authorization;openid-connect;oidc;oidc-provider;fapi;fapi-client;user-managed-access;financial-security black_cockpit.png MIT - https://github.com/sugarst/NETCore.Keycloak.git + https://github.com/Black-Cockpit/NETCore.Keycloak.git git README.md - https://github.com/sugarst/NETCore.Keycloak + https://github.com/Black-Cockpit/NETCore.Keycloak net10.0 latest enable From 176ff3cd794fad35da79bdf2b99bd43f300aab97 Mon Sep 17 00:00:00 2001 From: jeff Date: Wed, 4 Nov 2026 11:30:10 +0800 Subject: [PATCH 5/7] =?UTF-8?q?Validate=20PR=20updates=20/=20=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=20PR=20=E6=9B=B4=E6=96=B0=E4=B8=8E=E5=8F=8C=E7=89=88?= =?UTF-8?q?=E6=9C=AC=20Python=20=E5=B7=A5=E5=85=B7=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the branch naming policy on synchronize events so each PR revision receives a current check. Run dependency consistency and XML security regressions in both Python audit jobs. Validated actionlint and both isolated Python environments. --- .github/workflows/branch_naming_policy.yml | 1 + .github/workflows/keycloak_2681.yml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/branch_naming_policy.yml b/.github/workflows/branch_naming_policy.yml index b62d1a0..de2598f 100644 --- a/.github/workflows/branch_naming_policy.yml +++ b/.github/workflows/branch_naming_policy.yml @@ -7,6 +7,7 @@ on: types: - opened - reopened + - synchronize jobs: branch-naming-policy: runs-on: ubuntu-24.04 diff --git a/.github/workflows/keycloak_2681.yml b/.github/workflows/keycloak_2681.yml index 3c78e19..cc1dcdc 100644 --- a/.github/workflows/keycloak_2681.yml +++ b/.github/workflows/keycloak_2681.yml @@ -27,6 +27,9 @@ jobs: python -m venv NETCore.Keycloak.Client.Tests/obj/audited-tools NETCore.Keycloak.Client.Tests/obj/audited-tools/bin/python -m pip install \ --require-hashes -r NETCore.Keycloak.Client.Tests/requirements.txt + NETCore.Keycloak.Client.Tests/obj/audited-tools/bin/python -m pip check + NETCore.Keycloak.Client.Tests/obj/audited-tools/bin/python -m unittest discover \ + -s NETCore.Keycloak.Client.Tests -p test_verify_test_results.py python -m pip install pip-audit==2.10.1 mkdir -p NETCore.Keycloak.Client.Tests/obj/dependency-audit # Hashes were verified above. Manifest mode also audits bootstrap pins such as packaging. From f1934af4dc362cc174c75e95023d6ddc86fe4eb5 Mon Sep 17 00:00:00 2001 From: jeff Date: Wed, 4 Nov 2026 12:13:16 +0800 Subject: [PATCH 6/7] =?UTF-8?q?Use=20native=20.NET=2010=20test=20tooling?= =?UTF-8?q?=20/=20=E7=BB=9F=E4=B8=80=20.NET=2010=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Python/Ansible/Make test infrastructure with BCL-only .NET 10 file-based applications and isolated Docker Compose services. Preserve Keycloak realm, client, role, scope and JavaScript policy fixtures in JSON. Keep client APIs and all existing MSTest scenarios unchanged. Remove Python manifests, helper tests and CI version matrices. Run native TRX/Cobertura security checks and direct/transitive NuGet audits instead. Update Cake, documentation and FOSSA test inventory; resolve CLI tools via PATH and mask Sonar credentials in process arguments. 使用仅依赖 .NET 基础类库的单文件应用和隔离 Docker Compose 环境, 替换 Python、Ansible、Make 工具链,保留原有客户端功能和 MSTest 场景。 迁移 Realm、客户端、角色、作用域和 JavaScript 策略配置,更新 CI、 Cake、双语说明和 FOSSA 清单,保留完整依赖与许可证审计。 Validation / 验证: - Keycloak 26.8.1 + PostgreSQL 16: 434/434 passed, zero failures/skips. - FGAP v2: 31/31 passed; independent MSTest category: 54/54 passed. - Native verifier: 22 regressions passed on Windows and Linux. - HTTP method coverage: 178/178 methods across 13 modules. - Coverage: 99.65% lines (3517/3529), 83.72% branches (494/590). - NuGet audit: no reported direct/transitive vulnerabilities. - Cake compile/tool checks, actionlint and staged credential checks passed. - FOSSA local profiles: production 25 deps/7 direct; tests 27 deps/8 direct. Refs Black-Cockpit/NETCore.Keycloak#28. --- .editorconfig | 4 - .fossa-tests.yml | 6 +- .github/workflows/build_test_analyze.yml | 12 +- .github/workflows/keycloak_2681.yml | 83 +- NETCore.Keycloak.Client.Tests/Makefile | 94 --- .../NETCore.Keycloak.Client.Tests.csproj | 4 - NETCore.Keycloak.Client.Tests/README.md | 192 ++--- NETCore.Keycloak.Client.Tests/ansible.cfg | 21 - .../ansible/README.md | 120 --- .../ansible/provision_keycloak.yml | 70 -- .../ansible/requirements.yml | 11 - .../ansible/scripts/__init__.py | 0 .../ansible/scripts/assign_admin_roles.py | 195 ----- .../scripts/delete_client_default_authz.py | 184 ----- .../ansible/tasks/common/generate_secrets.yml | 77 -- .../ansible/tasks/database/setup.yml | 26 - .../generate_services_environment.yml | 38 - .../environment/generate_test_environment.yml | 17 - .../keycloak/client_scopes/attributes.yml | 42 - .../keycloak/client_scopes/audiences.yml | 27 - .../tasks/keycloak/clients/app_client.yml | 70 -- .../tasks/keycloak/clients/auth_client.yml | 267 ------- .../ansible/tasks/keycloak/create_realm.yml | 123 --- .../set_realm_user_profile_attributes.yml | 105 --- .../ansible/tasks/keycloak/setup.yml | 105 --- .../ansible/templates/compose.yml.j2 | 51 -- .../ansible/templates/keycloak.env.j2 | 23 - .../ansible/templates/postgres.env.j2 | 3 - .../templates/testing_environment.json.j2 | 33 - .../ansible/utils/extra/pre_flight.yml | 41 - .../ansible/vars/kc_realm.yml | 98 --- .../ansible/vars/main.yml | 68 -- .../build_test_analyse.cake | 40 +- .../cakeScripts/check_tools.cake | 71 +- .../setup_keycloak_test_environment.cake | 37 +- .../cakeScripts/sonar_analysis.cake | 27 +- NETCore.Keycloak.Client.Tests/e2e_test.cake | 44 +- NETCore.Keycloak.Client.Tests/inventory.ini | 1 - .../requirements-report.in | 2 - .../requirements-report.txt | 6 - NETCore.Keycloak.Client.Tests/requirements.in | 8 - .../requirements.txt | 732 ------------------ .../test_verify_test_results.py | 62 -- .../verify_test_results.py | 75 -- README.md | 57 +- tools/.editorconfig | 3 + tools/KeycloakTestEnvironment.cs | 330 ++++++++ tools/VerifyTestResults.cs | 265 +++++++ .../META-INF/keycloak-scripts.json | 0 .../js-policies/can-delete-roles.js | 0 .../js-policies/can-view-roles.js | 0 .../js-policies/can-write-roles.js | 0 .../js-policies/should-be-account-owner.js | 0 .../should-be-business-account-owner.js | 0 .../test-environment}/nginx.default.conf | 0 tools/test-environment/realm-fixture.json | 712 +++++++++++++++++ 56 files changed, 1498 insertions(+), 3184 deletions(-) delete mode 100644 NETCore.Keycloak.Client.Tests/Makefile delete mode 100644 NETCore.Keycloak.Client.Tests/ansible.cfg delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/README.md delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/provision_keycloak.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/requirements.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/scripts/__init__.py delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/scripts/assign_admin_roles.py delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/scripts/delete_client_default_authz.py delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/tasks/common/generate_secrets.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/tasks/database/setup.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_services_environment.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_test_environment.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/client_scopes/attributes.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/client_scopes/audiences.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/app_client.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/auth_client.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/create_realm.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/set_realm_user_profile_attributes.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/setup.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/templates/compose.yml.j2 delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/templates/keycloak.env.j2 delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/templates/postgres.env.j2 delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/templates/testing_environment.json.j2 delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/utils/extra/pre_flight.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/vars/kc_realm.yml delete mode 100644 NETCore.Keycloak.Client.Tests/ansible/vars/main.yml delete mode 100644 NETCore.Keycloak.Client.Tests/inventory.ini delete mode 100644 NETCore.Keycloak.Client.Tests/requirements-report.in delete mode 100644 NETCore.Keycloak.Client.Tests/requirements-report.txt delete mode 100644 NETCore.Keycloak.Client.Tests/requirements.in delete mode 100644 NETCore.Keycloak.Client.Tests/requirements.txt delete mode 100644 NETCore.Keycloak.Client.Tests/test_verify_test_results.py delete mode 100644 NETCore.Keycloak.Client.Tests/verify_test_results.py create mode 100644 tools/.editorconfig create mode 100644 tools/KeycloakTestEnvironment.cs create mode 100644 tools/VerifyTestResults.cs rename {NETCore.Keycloak.Client.Tests/ansible/files => tools/test-environment}/js-policies/META-INF/keycloak-scripts.json (100%) rename {NETCore.Keycloak.Client.Tests/ansible/files => tools/test-environment}/js-policies/can-delete-roles.js (100%) rename {NETCore.Keycloak.Client.Tests/ansible/files => tools/test-environment}/js-policies/can-view-roles.js (100%) rename {NETCore.Keycloak.Client.Tests/ansible/files => tools/test-environment}/js-policies/can-write-roles.js (100%) rename {NETCore.Keycloak.Client.Tests/ansible/files => tools/test-environment}/js-policies/should-be-account-owner.js (100%) rename {NETCore.Keycloak.Client.Tests/ansible/files => tools/test-environment}/js-policies/should-be-business-account-owner.js (100%) rename {NETCore.Keycloak.Client.Tests/ansible/files => tools/test-environment}/nginx.default.conf (100%) create mode 100644 tools/test-environment/realm-fixture.json diff --git a/.editorconfig b/.editorconfig index 523f92b..a32d682 100644 --- a/.editorconfig +++ b/.editorconfig @@ -514,7 +514,3 @@ indent_size = 4 [*.sh] indent_size = 4 end_of_line = lf - -[**/*.venv/**/*.cs] -ignore = true -generated_code = true diff --git a/.fossa-tests.yml b/.fossa-tests.yml index 4fab777..95f35f4 100644 --- a/.fossa-tests.yml +++ b/.fossa-tests.yml @@ -1,11 +1,9 @@ version: 3 # Use a separate FOSSA test-infrastructure project/revision with this config. -# Keep Ansible/PostgreSQL tooling visible for license review instead of -# ignoring its GPL/LGPL licenses in the published-library policy. +# Test tooling uses .NET 10 file-based applications with BCL-only dependencies. +# Inventory restored test NuGet dependencies, including transitives. targets: only: - type: nuget path: NETCore.Keycloak.Client.Tests/obj - - type: setuptools - path: NETCore.Keycloak.Client.Tests diff --git a/.github/workflows/build_test_analyze.yml b/.github/workflows/build_test_analyze.yml index f3bf099..f57405b 100644 --- a/.github/workflows/build_test_analyze.yml +++ b/.github/workflows/build_test_analyze.yml @@ -33,12 +33,6 @@ jobs: distribution: "temurin" java-version: ${{ matrix.java-version }} - # Setup Python - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - # Install the supported .NET SDK - name: Install .NET 10 SDK uses: actions/setup-dotnet@v4 @@ -70,7 +64,6 @@ jobs: - name: Install dependencies if: steps.sonar.outputs.available == 'true' run: | - sudo apt install -y make dotnet tool install --global dotnet-sonarscanner dotnet tool install --global Cake.Tool dotnet tool install --global JetBrains.dotCover.CommandLineTools @@ -80,7 +73,6 @@ jobs: if: steps.sonar.outputs.available == 'true' env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - VIRTUAL_ENV_DIR: keycloak.venv run: | # Copy Licence cp LICENSE NETCore.Keycloak.Client/ @@ -88,3 +80,7 @@ jobs: # Build, test and analyze project with keycloak version 26 cd NETCore.Keycloak.Client.Tests dotnet cake build_test_analyse.cake --kc_major_version=26 --sonar_token="${SONAR_TOKEN}" + + - name: Stop the managed Sonar test environment + if: always() && steps.sonar.outputs.available == 'true' + run: dotnet run --file tools/KeycloakTestEnvironment.cs -- down diff --git a/.github/workflows/keycloak_2681.yml b/.github/workflows/keycloak_2681.yml index cc1dcdc..bffbb22 100644 --- a/.github/workflows/keycloak_2681.yml +++ b/.github/workflows/keycloak_2681.yml @@ -11,59 +11,31 @@ permissions: contents: read jobs: - dependencies: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - strategy: - matrix: - python: ['3.12', '3.14'] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python }} - - name: Audit locked Python test dependencies - run: | - python -m venv NETCore.Keycloak.Client.Tests/obj/audited-tools - NETCore.Keycloak.Client.Tests/obj/audited-tools/bin/python -m pip install \ - --require-hashes -r NETCore.Keycloak.Client.Tests/requirements.txt - NETCore.Keycloak.Client.Tests/obj/audited-tools/bin/python -m pip check - NETCore.Keycloak.Client.Tests/obj/audited-tools/bin/python -m unittest discover \ - -s NETCore.Keycloak.Client.Tests -p test_verify_test_results.py - python -m pip install pip-audit==2.10.1 - mkdir -p NETCore.Keycloak.Client.Tests/obj/dependency-audit - # Hashes were verified above. Manifest mode also audits bootstrap pins such as packaging. - pip-audit --strict --disable-pip --require-hashes --timeout 60 \ - -r NETCore.Keycloak.Client.Tests/requirements.txt \ - --format json --output NETCore.Keycloak.Client.Tests/obj/dependency-audit/pip-audit.json - - name: Upload dependency audit - if: always() - uses: actions/upload-artifact@v4 - with: - name: python-dependency-audit-${{ matrix.python }} - path: NETCore.Keycloak.Client.Tests/obj/dependency-audit/pip-audit.json unit: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - - name: Verify report-parser security boundaries + - name: Verify native report-parser security boundaries + run: dotnet run --file tools/VerifyTestResults.cs -- --self-test + - name: Audit direct and transitive NuGet dependencies run: | - python -m pip install --require-hashes -r NETCore.Keycloak.Client.Tests/requirements-report.txt - python -m unittest discover -s NETCore.Keycloak.Client.Tests -p test_verify_test_results.py + dotnet restore NETCore.Keycloak.slnx + mkdir -p NETCore.Keycloak.Client.Tests/obj/dependency-audit + dotnet list NETCore.Keycloak.slnx package --vulnerable --include-transitive --format json \ + > NETCore.Keycloak.Client.Tests/obj/dependency-audit/nuget.json + dotnet run --file tools/VerifyTestResults.cs -- \ + --audit-json NETCore.Keycloak.Client.Tests/obj/dependency-audit/nuget.json - name: Run independent coverage unit tests run: | dotnet test NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj \ --configuration Release --filter 'TestCategory=CoverageUnit' --collect 'XPlat Code Coverage' \ --logger 'trx;LogFileName=unit.trx' --results-directory NETCore.Keycloak.Client.Tests/obj/unit-verification - python3 NETCore.Keycloak.Client.Tests/verify_test_results.py NETCore.Keycloak.Client.Tests/obj/unit-verification - - name: Upload unit test reports + dotnet run --file tools/VerifyTestResults.cs -- NETCore.Keycloak.Client.Tests/obj/unit-verification + - name: Upload unit reports and dependency audit if: always() uses: actions/upload-artifact@v4 with: @@ -71,60 +43,49 @@ jobs: path: | NETCore.Keycloak.Client.Tests/obj/unit-verification/**/*.trx NETCore.Keycloak.Client.Tests/obj/unit-verification/**/coverage.cobertura.xml + NETCore.Keycloak.Client.Tests/obj/dependency-audit/nuget.json compatibility: runs-on: ubuntu-24.04 timeout-minutes: 30 strategy: fail-fast: false matrix: - include: - - fgap: v1 - python: '3.12' - - fgap: v2 - python: '3.12' - - fgap: v1 - python: '3.14' - defaults: - run: - working-directory: NETCore.Keycloak.Client.Tests - shell: bash + fgap: [v1, v2] env: - KC_VERSION: 26.8.1 KC_FGAP_VERSION: ${{ matrix.fgap }} steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python }} - uses: actions/setup-dotnet@v4 with: dotnet-version: 10.0.x - name: Prepare isolated Keycloak 26.8.1 environment - run: make prepare_keycloak_26_environment + run: dotnet run --file tools/KeycloakTestEnvironment.cs -- up --fgap "$KC_FGAP_VERSION" - name: Run complete suite or FGAP v2 regressions + shell: bash run: | filter=() if [[ "$KC_FGAP_VERSION" == "v2" ]]; then # Legacy /management/permissions APIs require FGAP v1 and are covered by the full v1 job. filter=(--filter 'TestCategory=Keycloak2681&TestCategory!=LegacyAdminPermissions') fi - dotnet test NETCore.Keycloak.Client.Tests.csproj --framework net10.0 --configuration Release \ + dotnet test NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj --framework net10.0 --configuration Release \ "${filter[@]}" --logger 'trx;LogFileName=keycloak-26.8.1.trx' \ - --results-directory "obj/keycloak-26.8.1/$KC_FGAP_VERSION" --collect 'XPlat Code Coverage' + --results-directory "NETCore.Keycloak.Client.Tests/obj/keycloak-26.8.1/$KC_FGAP_VERSION" --collect 'XPlat Code Coverage' coverage_check=() if [[ "$KC_FGAP_VERSION" == "v1" ]]; then coverage_check=(--require-http-method-coverage) fi - python verify_test_results.py "obj/keycloak-26.8.1/$KC_FGAP_VERSION" "${coverage_check[@]}" + dotnet run --file tools/VerifyTestResults.cs -- \ + "NETCore.Keycloak.Client.Tests/obj/keycloak-26.8.1/$KC_FGAP_VERSION" "${coverage_check[@]}" - name: Upload test and coverage reports if: always() uses: actions/upload-artifact@v4 with: - name: keycloak-26.8.1-fgap-${{ matrix.fgap }}-python-${{ matrix.python }} + name: keycloak-26.8.1-fgap-${{ matrix.fgap }} path: | NETCore.Keycloak.Client.Tests/obj/keycloak-26.8.1/**/*.trx NETCore.Keycloak.Client.Tests/obj/keycloak-26.8.1/**/coverage.cobertura.xml if-no-files-found: warn - - name: Stop the test environment + - name: Stop the managed test environment if: always() - run: make stop + run: dotnet run --file tools/KeycloakTestEnvironment.cs -- down diff --git a/NETCore.Keycloak.Client.Tests/Makefile b/NETCore.Keycloak.Client.Tests/Makefile deleted file mode 100644 index 6a1daeb..0000000 --- a/NETCore.Keycloak.Client.Tests/Makefile +++ /dev/null @@ -1,94 +0,0 @@ -# Makefile -# Automates the setup, configuration, and management of Keycloak environments -# using Ansible and a Python virtual environment. - -# Define the shell to be used for commands -SHELL := /bin/bash - -# Targets that are not associated with files -.PHONY: install_virtual_env check_virtual_env prepare_keycloak_20_environment prepare_keycloak_21_environment \ - prepare_keycloak_22_environment prepare_keycloak_23_environment prepare_keycloak_24_environment \ - prepare_keycloak_25_environment prepare_keycloak_26_environment stop - -# Extract the name of the current Makefile -# Useful for debugging or referencing the Makefile itself -CURRENT_MK := $(lastword $(MAKEFILE_LIST)) - -# Configuration for the virtual environment -# Virtual environment directory name -VIRTUAL_ENV_DIR := keycloak.venv -export VIRTUAL_ENV_DIR - -# Pin the current compatibility target; select v2 for the dedicated security regression profile. -KC_VERSION ?= 26.8.1 -KC_FGAP_VERSION ?= v1 - -# Directory context for the project -CONF_DIR_CONTEXT := "." - -# ----------------------------------- -# Target: install_virtual_env -# ----------------------------------- -# Sets up a Python virtual environment for the project. -# If an existing virtual environment is found, it deletes and recreates it. -# Installs all Python dependencies and required Ansible collections. -install_virtual_env: - @echo "Checking if directory ${CONF_DIR_CONTEXT}/${VIRTUAL_ENV_DIR} exists..." - @if [ -d "${CONF_DIR_CONTEXT}/${VIRTUAL_ENV_DIR}" ]; then \ - echo "Directory ${CONF_DIR_CONTEXT}/${VIRTUAL_ENV_DIR} already exists."; \ - echo "Deleting directory ${CONF_DIR_CONTEXT}/${VIRTUAL_ENV_DIR}"; \ - rm -r ${CONF_DIR_CONTEXT}/${VIRTUAL_ENV_DIR}; \ - mkdir ${CONF_DIR_CONTEXT}/${VIRTUAL_ENV_DIR}; \ - fi - @echo "Creating directory ${CONF_DIR_CONTEXT}/${VIRTUAL_ENV_DIR}" - # Create a new virtual environment - @python3 -m venv ${CONF_DIR_CONTEXT}/${VIRTUAL_ENV_DIR} - # Activate the virtual environment and install dependencies - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && python -m pip install --require-hashes -r requirements.txt - # Install required Ansible collections - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-galaxy collection install --no-cache -r ansible/requirements.yml - -# ----------------------------------- -# Target: check_virtual_env -# ----------------------------------- -# Checks if the virtual environment directory exists. If it doesn't, calls install_virtual_env. -check_virtual_env: - @if [ ! -d "${CONF_DIR_CONTEXT}/${VIRTUAL_ENV_DIR}" ]; then \ - echo "Virtual environment directory ${CONF_DIR_CONTEXT}/${VIRTUAL_ENV_DIR} not found."; \ - echo "Creating virtual environment..."; \ - $(MAKE) install_virtual_env; \ - fi - -# ----------------------------------- -# Target: prepare_keycloak__environment -# ----------------------------------- -# Prepares a Keycloak environment for the specified version. -# Ensures the environment is stopped before setting up the specified version. -prepare_keycloak_20_environment: check_virtual_env stop - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-playbook ansible/provision_keycloak.yml -e "stack_state=present" -e "kc_version=20.0.3" - -prepare_keycloak_21_environment: check_virtual_env stop - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-playbook ansible/provision_keycloak.yml -e "stack_state=present" -e "kc_version=21.1.2" - -prepare_keycloak_22_environment: check_virtual_env stop - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-playbook ansible/provision_keycloak.yml -e "stack_state=present" -e "kc_version=22.0.3" - -prepare_keycloak_23_environment: check_virtual_env stop - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-playbook ansible/provision_keycloak.yml -e "stack_state=present" -e "kc_version=23.0.7" - -prepare_keycloak_24_environment: check_virtual_env stop - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-playbook ansible/provision_keycloak.yml -e "stack_state=present" -e "kc_version=24.0.5-0" - -prepare_keycloak_25_environment: check_virtual_env stop - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-playbook ansible/provision_keycloak.yml -e "stack_state=present" -e "kc_version=25.0.6" - -prepare_keycloak_26_environment: check_virtual_env stop - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-playbook ansible/provision_keycloak.yml -e "stack_state=present" -e "kc_version=$(KC_VERSION)" -e "kc_fgap_version=$(KC_FGAP_VERSION)" - -# ----------------------------------- -# Target: stop -# ----------------------------------- -# Stops the current Keycloak environment. -# Executes an Ansible playbook with the `stack_state=absent` parameter to remove the environment. -stop: - @pushd ${CONF_DIR_CONTEXT}/ && source ${VIRTUAL_ENV_DIR}/bin/activate && ansible-playbook ansible/provision_keycloak.yml -e "stack_state=absent" diff --git a/NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj b/NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj index 5e5751c..713ba8c 100644 --- a/NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj +++ b/NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj @@ -29,10 +29,6 @@ - - - - PreserveNewest diff --git a/NETCore.Keycloak.Client.Tests/README.md b/NETCore.Keycloak.Client.Tests/README.md index 8879909..f0a6d47 100644 --- a/NETCore.Keycloak.Client.Tests/README.md +++ b/NETCore.Keycloak.Client.Tests/README.md @@ -1,105 +1,82 @@ # NETCore.Keycloak.Client Tests -This directory contains the comprehensive test suite for the NETCore.Keycloak.Client library. The test suite is designed to validate the library's functionality across multiple versions of Keycloak (20.x through 26.x) using automated test environments and continuous integration. - -The test suite implements: -- End-to-end testing of all Keycloak client features -- Authentication flows (Client Credentials, Resource Owner Password) -- Authorization mechanisms (Bearer tokens, RPT tokens) -- Client and realm management operations -- Group and role management -- User operations and session handling - -Key features of the test infrastructure: -- Automated Keycloak environment setup using Ansible -- Docker-based test environments for each Keycloak version -- Parallel test execution capabilities -- Comprehensive mock data fixtures -- Detailed test reporting +The .NET 10/MSTest suite validates the existing client API against Keycloak **26.8.1**. +All test tooling uses C#: native .NET 10 file-based applications provision Docker Compose +services and verify TRX/Cobertura reports. No Python, pip, Ansible, Make or additional +NuGet packages are needed by these tools. Existing client and MSTest functionality is unchanged. +测试、环境搭建和报告校验统一使用 .NET 10;保留现有测试场景及客户端功能。 ## Test Environment Setup -### Prerequisites +Prerequisites: **.NET 10 SDK**, **Docker Engine/Desktop** and **Docker Compose v2**. +Run the commands below from the repository root, on Windows, Linux or macOS with Docker configured. +Use `--file` explicitly so the SDK selects the file-based application instead of a nearby project. -1. **Python Environment**: - - Python 3.12 or 3.14 on Linux/WSL (the CI validation matrix) - - pip (Python package installer) - - virtualenv - -2. **Docker**: - - Docker Engine - - Docker Compose v2 - -3. **.NET SDK**: - - .NET 10.0 SDK - -4. **Build Tools**: - - Cake (C# Make) - - Make +```bash +dotnet run --file tools/KeycloakTestEnvironment.cs -- up +``` -### Setting Up Python Environment +The tool creates an isolated Compose project (`netcore-keycloak-tests`) with Keycloak 26.8.1, +PostgreSQL 16 and an nginx invalid-response fixture. It imports `tools/test-environment/realm-fixture.json`, +packages the existing JavaScript authorization policies, and assigns the fixture administrator's roles. +The test realm deliberately uses a low password hash iteration count for test speed; this fixture is +for local/CI tests only. Published service ports bind to loopback. Credentials are generated with +`RandomNumberGenerator`, are never printed, and are written only to ignored local configuration. +On Unix, generated secret files have mode 600 and the state directory has mode 700. +The generated provider JAR remains readable by Keycloak's non-root container user. -The project uses a Makefile to automate the Python environment setup. The following commands are available: +The default output is `NETCore.Keycloak.Client.Tests/Assets/testing_environment.json`. +Generate it before building so the test project copies it to the test output directory. +Do not commit this file. To keep an existing local environment/configuration intact, choose a +separate project, ports and configuration path: ```bash -# Install virtual environment and dependencies -make install_virtual_env - -# This will: -# 1. Create a Python virtual environment (keycloak.venv) -# 2. Install required Python packages from requirements.txt -# 3. Install necessary Ansible collections: -# - community.general -# - community.postgresql -# - community.crypto -# - community.docker +dotnet run --file tools/KeycloakTestEnvironment.cs -- up --project netcore-keycloak-review \ + --port 18181 --invalid-port 18898 --config NETCore.Keycloak.Client.Tests/obj/review/testing_environment.json ``` -Only `ansible-core`, the four direct collections and Docker's inventory-filtering helper pinned -in `ansible/requirements.yml` are installed; -the full Ansible distribution and the unused python-jose/ecdsa/rsa stack are not required. -The fixture scripts still require `python-keycloak`. They use the same Python interpreter as Ansible. -`requirements.in` lists direct dependencies; `requirements.txt` locks the resolved Python 3.12/Linux -dependencies and their hashes. The same lock is also validated on Python 3.14. -Regenerate it from the repository root after reviewing an update: +A custom configuration path must be copied into the test output's `Assets/testing_environment.json` +before running tests with `--no-build`. Only one run may use a shared configuration/output directory. +The tool refuses to overwrite an existing managed environment. Clean up that same project before +recreating it or switching between `--fgap v1` (default) and `--fgap v2`: ```bash -uv pip compile NETCore.Keycloak.Client.Tests/requirements.in --upgrade --python-version 3.12 \ - --python-platform x86_64-manylinux_2_28 --generate-hashes --index-url https://pypi.org/simple \ - --no-emit-index-url --output-file NETCore.Keycloak.Client.Tests/requirements.txt -python -m pip install --require-hashes -r NETCore.Keycloak.Client.Tests/requirements.txt -pip-audit --strict --disable-pip --require-hashes -r NETCore.Keycloak.Client.Tests/requirements.txt +dotnet run --file tools/KeycloakTestEnvironment.cs -- down --project netcore-keycloak-review ``` -CI first installs with hash verification, then audits every manifest pin without vulnerability -exceptions. Manifest mode avoids omitting bootstrap dependencies such as `packaging` during -temporary-environment resolution. The TRX/Cobertura verifier rejects DTDs, -entities, missing reports and skipped tests. Its independent security tests need only the small -`requirements-report.txt` lock: +Cleanup checks the generated ownership marker and removes only that Compose project's containers, +network and database volume. It leaves test reports/configuration available for inspection. +State is stored under `NETCore.Keycloak.Client.Tests/obj/native-environment/`. +If provisioning fails, run `down` for the same project before retrying. + +### Native report and dependency checks ```bash -python -m pip install --require-hashes -r NETCore.Keycloak.Client.Tests/requirements-report.txt -python -m unittest discover -s NETCore.Keycloak.Client.Tests -p test_verify_test_results.py +dotnet run --file tools/VerifyTestResults.cs -- --self-test +dotnet restore NETCore.Keycloak.slnx +dotnet list NETCore.Keycloak.slnx package --vulnerable --include-transitive --format json > nuget-audit.json +dotnet run --file tools/VerifyTestResults.cs -- --audit-json nuget-audit.json ``` -The FOSSA CLI library profile `.fossa.yml` reads the restored library's `obj/project.assets.json`, -including transitive dependencies. Restore the solution before scanning; excluding all `obj` paths -would discard that dependency graph. `.fossa-tests.yml` separately inventories test NuGet packages -and Python tools. Validate both profiles locally without uploading: +The report verifier rejects missing/empty/incomplete runs, failed or skipped outcomes, DTDs and +XML entities. Its native self-tests also protect async state-machine coverage mapping and NuGet +audit rejection. The audit covers both direct and transitive NuGet dependencies; the file-based +tools themselves use only the .NET base class library. + +The FOSSA CLI profiles inventory restored production (`.fossa.yml`) and test (`.fossa-tests.yml`) +NuGet graphs separately. Restore before scanning; excluding every `obj` path would discard the +dependency graph. Validate both profiles locally without uploading: ```bash -dotnet restore NETCore.Keycloak.slnx fossa analyze --config .fossa.yml --output > library-dependencies.json fossa analyze --config .fossa-tests.yml --output > test-dependencies.json ``` -Both outputs must contain their expected targets and nonempty dependency graphs. A FOSSA maintainer -must apply equivalent scopes to OAuth/Quick Import projects if that integration does not consume CLI -configuration. Scope changes and a new scan do not approve licenses or change existing FOSSA policy. -Ansible-core/collections and the PostgreSQL driver retain their own GPL/LGPL or other notices; -they are test infrastructure and are not included in the published client NuGet package. Review -their use/distribution separately from the library's MIT license. Do not blanket-ignore their findings. -生产包与测试工具分别审计;测试依赖仍检查漏洞和许可证,不通过整体忽略来消除告警。 +A FOSSA maintainer must apply equivalent scopes to OAuth/Quick Import projects if that integration +does not consume CLI configuration. Removing unused dependencies does not approve licenses or +change policy. Container images have their own third-party software and distribution obligations; +they are test infrastructure and are not embedded in the published client NuGet package. +生产包、测试 NuGet 依赖与容器镜像分别审计,不通过整体忽略依赖或许可证来消除告警。 ## Running Tests @@ -141,15 +118,11 @@ Local verification on 2026-11-03, .NET 10 Release and Keycloak 26.8.1: | Branch coverage | 70.59% | 83.72% | | Unexecuted source lines | 255 | 12 | -Revalidated on 2026-11-04 with Linux .NET 10 and Keycloak 26.8.1 backed by PostgreSQL 16: -fixtures provisioned with Python 3.12.16 and Python 3.14.10 each passed all 434 tests, with no -failures/skips and all 178 declared client methods executed. FGAP v2 passed its 31 applicable -regressions. Each Python version passed the five XML verifier security tests; the locked -26-package toolchain had no known matches in the independent OSV check. The production FOSSA -profile includes 7 direct and 25 total NuGet dependencies; the test profile retains both test -NuGet and Python inventories. Local Docker orchestration used the existing Windows Docker -host because that WSL distribution has no Docker integration. GitHub's Ubuntu 24.04 jobs still -require upstream approval and must pass before merge. +Revalidated on 2026-11-04 using native .NET 10 provisioning, Keycloak 26.8.1 and PostgreSQL 16: +all 434 FGAP v1 tests passed without failures or skips, and all 178 declared client methods +executed. The same native tool provisions FGAP v2 for its separate 31 applicable regressions. +The report verifier has 22 native self-tests. GitHub's Ubuntu 24.04 jobs still require upstream +approval and must pass before merge. The 12 remaining lines are internal argument/null guards, the defensive null token-response return, repeated-disposal handling and the finalizer path. No reflection into private methods, @@ -159,7 +132,7 @@ forced garbage collection or coverage exclusions were added to inflate the resul ### Run the complete .NET 10 test suite The complete suite includes integration tests that create, update, and delete Keycloak resources. -Use a dedicated test environment prepared with the Ansible/Cake setup described below. +Use a dedicated test environment prepared with the native .NET tool described above. The generated `Assets/testing_environment.json` must exist in this test project before building; the project copies it to the test output directory. This file contains local credentials and is intentionally excluded from Git. A missing file causes integration test classes to fail during @@ -204,24 +177,26 @@ the operation under test uses the intended user or delegated administrator token Original `A_`–`Z_` tests still use the dedicated shared integration realm. Do not run either suite against production. -From this test directory on a dedicated Linux test host (or WSL with Docker configured): +From the repository root on a dedicated Docker test host: ```bash -make prepare_keycloak_26_environment KC_VERSION=26.8.1 KC_FGAP_VERSION=v1 -dotnet test NETCore.Keycloak.Client.Tests.csproj --configuration Release \ - --logger 'trx;LogFileName=full.trx' --results-directory obj/verification/v1 \ +dotnet run --file tools/KeycloakTestEnvironment.cs -- up --fgap v1 +dotnet test NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj --configuration Release \ + --logger 'trx;LogFileName=full.trx' --results-directory NETCore.Keycloak.Client.Tests/obj/verification/v1 \ --collect 'XPlat Code Coverage' -python verify_test_results.py obj/verification/v1 --require-http-method-coverage -make stop +dotnet run --file tools/VerifyTestResults.cs -- NETCore.Keycloak.Client.Tests/obj/verification/v1 --require-http-method-coverage +dotnet run --file tools/KeycloakTestEnvironment.cs -- down -make prepare_keycloak_26_environment KC_VERSION=26.8.1 KC_FGAP_VERSION=v2 -dotnet test NETCore.Keycloak.Client.Tests.csproj --configuration Release \ +dotnet run --file tools/KeycloakTestEnvironment.cs -- up --fgap v2 +dotnet test NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj --configuration Release \ --filter 'TestCategory=Keycloak2681&TestCategory!=LegacyAdminPermissions' --logger 'trx;LogFileName=fgap-v2.trx' \ - --results-directory obj/verification/v2 -python verify_test_results.py obj/verification/v2 -make stop + --results-directory NETCore.Keycloak.Client.Tests/obj/verification/v2 +dotnet run --file tools/VerifyTestResults.cs -- NETCore.Keycloak.Client.Tests/obj/verification/v2 +dotnet run --file tools/KeycloakTestEnvironment.cs -- down ``` +Use a fresh results directory for each run. The verifier rejects any stale failed/skipped TRX files +in that directory. In automated jobs, always run `down` in a cleanup/finally step, including after failure. The full v1 profile also exercises the client scope-mapping aliases, role-container evaluation, node registration/deletion, empty adapter topology checks, revocation propagation, composite-role removal and legacy `/management/permissions` APIs. The coverage gate requires execution of every @@ -236,7 +211,7 @@ allowing profile management while explicitly denying password reset. Only the ex 完整 v1 测试与 v2 安全回归互为补充;CI 要求两者都通过,且不允许失败或跳过。 `.github/workflows/keycloak_2681.yml` runs both profiles independently of Sonar credentials, -uses PostgreSQL through the standard Ansible setup, and publishes TRX/Cobertura artifacts. +uses PostgreSQL through the native .NET setup, and publishes TRX/Cobertura artifacts. Local dev-mode/H2 runs do not establish PostgreSQL or cluster compatibility by themselves. For an already prepared second server, `KC_TEST_BASE_URL` overrides only the server URL loaded @@ -735,26 +710,7 @@ NETCore.Keycloak.Client.Tests/ │ └── KcGroupTests/ # Group management tests │ ├── KcGroupFilterTests.cs │ └── KcGroupsHappyPathTests.cs -├── ansible/ # Environment automation +├── ../tools/ # Native environment/report tools and declarative fixtures ├── cakeScripts/ # Build automation scripts ├── e2e_test.cake # E2E test orchestration ``` - -## Makefile Details - -The Makefile automates several key aspects of the test environment: - -### Virtual Environment Setup -```makefile -install_virtual_env: - # Creates/recreates Python virtual environment - # Installs Python dependencies - # Installs required Ansible collections -``` - -### Environment Cleanup -```makefile -stop: - # Stops and removes the current Keycloak environment - # Cleans up containers and volumes -``` diff --git a/NETCore.Keycloak.Client.Tests/ansible.cfg b/NETCore.Keycloak.Client.Tests/ansible.cfg deleted file mode 100644 index 36a3fb9..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible.cfg +++ /dev/null @@ -1,21 +0,0 @@ -[defaults] -inventory = ./inventory.ini -gathering = explicit -host_key_checking = False -executable = /bin/bash -allow_world_readable_tmpfiles = True -callbacks_enabled = profile_tasks, profile_roles -forks=50 -ssh_args = -o ControlMaster=auto -o ControlPersist=60s -pipelining = True - -[inventory] -unparsed_is_failed=True -enable_plugins = script, ini - -[ssh_connection] -ansible_pipelining=True -retries=3 - -[persistent_connection] -command_timeout = 60 \ No newline at end of file diff --git a/NETCore.Keycloak.Client.Tests/ansible/README.md b/NETCore.Keycloak.Client.Tests/ansible/README.md deleted file mode 100644 index 4c6acbf..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/README.md +++ /dev/null @@ -1,120 +0,0 @@ -# Ansible Automation for Keycloak Testing Environment - -This directory contains Ansible automation scripts for setting up and managing a Keycloak testing environment. The automation handles everything from environment provisioning to Keycloak configuration and test setup. - -## Directory Structure - -``` -ansible/ -├── files/ # Static files used in provisioning -├── scripts/ # Helper scripts for automation -├── tasks/ # Ansible task definitions -│ ├── common/ # Common tasks shared across playbooks -│ ├── database/ # Database setup and configuration -│ ├── environment/ # Environment setup tasks -│ └── keycloak/ # Keycloak-specific tasks -├── templates/ # Jinja2 templates -├── utils/ # Utility scripts and helpers -└── vars/ # Variable definitions -``` - -## Main Components - -### 1. Provisioning Playbook - -The main playbook `provision_keycloak.yml` orchestrates the entire environment setup: - -- Pre-flight environment checks -- Secret generation -- Service environment configuration -- Docker container management -- Database setup -- Keycloak configuration -- Test environment generation - -### 2. Container Configuration - -Located in `containers/`: -- `Dockerfile`: Defines Keycloak container based on `quay.io/keycloak/keycloak:23.0.7` -- `compose.yml`: Docker Compose configuration for the entire stack - -### 3. Task Modules - -The `tasks/` directory contains specialized task modules: - -#### Common Tasks -- Secret generation -- Shared utilities -- Common configurations - -#### Database Tasks -- Database initialization -- Schema setup -- User management - -#### Environment Tasks -- Service environment generation -- Test environment setup -- Configuration management - -#### Keycloak Tasks -- Realm configuration -- Client setup -- User provisioning -- Role and policy management - -### 4. Templates and Variables - -- `templates/`: Jinja2 templates for generating configurations -- `vars/`: Variable definitions for different environments - -## Usage - -### Prerequisites -- Ansible -- Docker and Docker Compose -- Python 3.x - -### Basic Commands - -1. Start the environment: -```bash -ansible-playbook provision_keycloak.yml -e "stack_state=present" -``` - -2. Stop the environment: -```bash -ansible-playbook provision_keycloak.yml -e "stack_state=stopped" -``` - -3. Remove the environment: -```bash -ansible-playbook provision_keycloak.yml -e "stack_state=absent" -``` - -### Environment Variables - -Key variables that can be configured: - -- `stack_state`: Controls environment state (present/stopped/absent) -- `workspace`: Base directory for automation -- `containers_dir`: Location of container definitions - -## Troubleshooting - -Common issues and solutions: - -1. **Container Startup Issues**: - - Check Docker logs - - Verify port availability - - Ensure sufficient resources - -2. **Database Connection Issues**: - - Verify database credentials - - Check network connectivity - - Validate database initialization - -3. **Keycloak Configuration**: - - Review realm settings - - Check client configurations - - Verify user permissions \ No newline at end of file diff --git a/NETCore.Keycloak.Client.Tests/ansible/provision_keycloak.yml b/NETCore.Keycloak.Client.Tests/ansible/provision_keycloak.yml deleted file mode 100644 index f56ceea..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/provision_keycloak.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Provision Keycloak environment services -- name: Provision Keycloak environment services - hosts: localhost - connection: local - become: false - gather_facts: true - any_errors_fatal: yes - - # Pre-tasks are executed before the main tasks - pre_tasks: - # Include main variables - # This task loads all variable files from the specified directory. - - include_vars: - dir: "./vars/" - - # Preflight check - # Includes and executes the preflight check tasks to validate the environment and ensure prerequisites are met. - # This is skipped if the stack state is 'stopped' or 'absent'. - - include_tasks: - file: "{{ workspace }}/utils/extra/pre_flight.yml" - when: not (stack_state == 'stopped' or stack_state == 'absent') - - # Generate secrets - # Generates required secrets for the Keycloak environment. - # This is skipped if the stack state is 'stopped' or 'absent'. - - include_tasks: - file: "{{ workspace }}/tasks/common/generate_secrets.yml" - when: not (stack_state == 'stopped' or stack_state == 'absent') - - tasks: - # Generate services environment - # Includes tasks to generate environment configuration for Keycloak services. - # This step is skipped if the stack state is 'stopped' or 'absent'. - - include_tasks: - file: "{{ workspace }}/tasks/environment/generate_services_environment.yml" - when: not (stack_state == 'stopped' or stack_state == 'absent') - - # Manage Keycloak environment services - # Uses the Docker Compose v2 module to manage the Keycloak services. - # - Ensures the stack is built and running as per the specified stack state. - # - Removes orphan containers and unused volumes/images to maintain a clean state. - - name: Manage Keycloak environment services - community.docker.docker_compose_v2: - project_src: "{{ containers_dir }}/" - build: always - remove_orphans: true - remove_volumes: true - state: "{{ stack_state }}" - remove_images: local - recreate: always - ignore_errors: "{{ true if (stack_state == 'stopped' or stack_state == 'absent') else false }}" - - # Include database setup tasks - # Includes a separate task file for setting up the database when the stack state is not stopped or absent. - - include_tasks: - file: "{{ workspace }}/tasks/database/setup.yml" - when: not (stack_state == 'stopped' or stack_state == 'absent') - - # Include keycloak setup tasks - # Includes a separate task file for setting up the keycloak when the stack state is not stopped or absent. - - include_tasks: - file: "{{ workspace }}/tasks/keycloak/setup.yml" - when: not (stack_state == 'stopped' or stack_state == 'absent') - - # Generate test environment - # Includes the task file to generate the test environment, ensuring it is only executed - # when the stack state is not stopped or absent. - - include_tasks: - file: "{{ workspace }}/tasks/environment/generate_test_environment.yml" - when: not (stack_state == 'stopped' or stack_state == 'absent') diff --git a/NETCore.Keycloak.Client.Tests/ansible/requirements.yml b/NETCore.Keycloak.Client.Tests/ansible/requirements.yml deleted file mode 100644 index a4cd9be..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/requirements.yml +++ /dev/null @@ -1,11 +0,0 @@ -collections: - - name: community.general - version: "11.4.2" - - name: community.postgresql - version: "4.2.0" - - name: community.crypto - version: "3.0.5" - - name: community.docker - version: "4.8.5" - - name: community.library_inventory_filtering_v1 - version: "1.1.5" diff --git a/NETCore.Keycloak.Client.Tests/ansible/scripts/__init__.py b/NETCore.Keycloak.Client.Tests/ansible/scripts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/NETCore.Keycloak.Client.Tests/ansible/scripts/assign_admin_roles.py b/NETCore.Keycloak.Client.Tests/ansible/scripts/assign_admin_roles.py deleted file mode 100644 index 8acd3f4..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/scripts/assign_admin_roles.py +++ /dev/null @@ -1,195 +0,0 @@ -from typing import List -from keycloak import KeycloakOpenID, KeycloakAdmin -import argparse - - -def init_parser() -> argparse.ArgumentParser: - """ - Init argument parser - :return: ArgumentParser - """ - example_text = '''example: - python assign_admin_roles.py http://localhost:8080 -a admin -p admin -aR master -c admin-cli -r "manage-realm,create-client" -rN example -u user - ''' - - arg_parser = argparse.ArgumentParser(epilog=example_text, - formatter_class=argparse.RawDescriptionHelpFormatter) - - arg_parser.add_argument("kc_auth_url", type=str, - default="http://localhost:8080") - - arg_parser.add_argument("-a", "--auth_admin", type=str, - default='admin', help="Auth admin username") - - arg_parser.add_argument("-p", "--auth_admin_password", type=str, - default='admin', help="Auth admin password") - - arg_parser.add_argument("-aR", "--auth_realm", type=str, - default='master', help="Auth realm") - - arg_parser.add_argument("-c", "--client_id", type=str, - default='admin-cli', help="Client id") - - arg_parser.add_argument("-s", "--client_secret", - type=str, default=None, help="Client secret") - - arg_parser.add_argument("-r", "--roles", type=str, - default=None, help="Roles to be assigned to user") - - arg_parser.add_argument("-rN", "--realm_name", type=str, - default=None, help="Realm where the user is registered") - - arg_parser.add_argument("-u", "--admin_user", type=str, - default=None, help="Username where roles should be assigned to.") - - return arg_parser - - -def assign_roles(auth_url: str = 'http://localhost:8080', - auth_username: str = 'admin', - auth_password: str = 'admin', - auth_realm: str = 'master', - client_id: str = 'admin-cli', - client_secret: str = None, - realm_name: str = None, - admin_username: str = None, - user_roles: List[str] = []): - """Assign admin roles - - Args: - auth_url (str, required): keycloak auth url. Defaults to 'http://localhost:8080'. - auth_username (str, required): keycloak auth username. Defaults to 'admin'. - auth_password (str, required): keycloak auth password. Defaults to 'admin'. - auth_realm (str, required): keycloak auth realm. Defaults to 'master'. - client_id (str, required): keycloak auth client id. Defaults to 'admin-cli'. - client_secret (str, optional): keycloak auth client secret. Defaults to None. - realm_name (str, required): Realm where the user is registered. Defaults to None. - admin_username (str, required): Username where roles should be assigned to. Defaults to None. - user_roles (List[str], optional): Roles to be assigned to user. Defaults to []. - """ - if user_roles is None or len(user_roles) <= 0: - return - - # Configure client - keycloak_openid = KeycloakOpenID(server_url=auth_url, - client_id=client_id, - realm_name=auth_realm, - client_secret_key=client_secret) - - token = keycloak_openid.token(auth_username, auth_password) - - admin_client = KeycloakAdmin(server_url=auth_url, - client_id=client_id, - token=token, - realm_name=realm_name) - - realm_management_client_id = admin_client.get_client_id("realm-management") - realm_admin_id = admin_client.get_user_id(admin_username) - - if realm_admin_id is None: - print("Error, user not found") - exit(255) - - available_management_roles = admin_client.get_available_client_roles_of_user(realm_admin_id, - realm_management_client_id) - - if len(available_management_roles) <= 0: - return - - allowed_roles = [] - - for role in available_management_roles: - if role is not None and role['name'] is not None and role['name'] in user_roles: - allowed_roles.append(role) - - if len(allowed_roles) > 0: - admin_client.assign_client_role(realm_admin_id, - realm_management_client_id, - allowed_roles) - - -def parse_roles(roles: str) -> List[str]: - """ Parse roles to be assigned to admin user - - Args: - roles (str): Roles as string separated by a comma - - Returns: - List[str]: List of role name - """ - if roles is None or len(roles) <= 0: - return [] - else: - return roles.split(",") - - -if __name__ == '__main__': - parser = init_parser() - - auth_url = 'http://localhost:8080' - auth_username = 'admin' - auth_password = 'admin' - auth_realm = 'master' - client_id = 'admin-cli' - client_secret = '' - realm_name = '' - admin_username = '' - roles = '' - - try: - args = parser.parse_args() - - if args.roles: - roles = args.roles - else: - print(f'Warning : No role is set') - exit(0) - - if args.kc_auth_url: - auth_url = args.kc_auth_url - else: - print(f'Error : {parser.epilog}') - - if args.auth_admin: - auth_username = args.auth_admin - - if args.auth_admin_password: - auth_password = args.auth_admin_password - - if args.auth_realm: - auth_realm = args.auth_realm - - if args.client_id: - client_id = args.client_id - - if args.client_secret: - client_secret = args.client_secret - - if args.realm_name: - realm_name = args.realm_name - else: - print(f'Error realm name is required: {parser.epilog}') - - if args.admin_user: - admin_username = args.admin_user - else: - print(f'Error admin user is required: {parser.epilog}') - - assign_roles(auth_url=auth_url, - auth_username=auth_username, - auth_password=auth_password, - auth_realm=auth_realm, - client_id=client_id, - client_secret=client_secret, - admin_username=admin_username, - realm_name=realm_name, - user_roles=roles) - - except argparse.ArgumentError: - print(parser.epilog) - exit(255) - except KeyboardInterrupt: - exit(0) - except Exception as e: - print(f'Error : {e}') - exit(255) diff --git a/NETCore.Keycloak.Client.Tests/ansible/scripts/delete_client_default_authz.py b/NETCore.Keycloak.Client.Tests/ansible/scripts/delete_client_default_authz.py deleted file mode 100644 index 8ed0d81..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/scripts/delete_client_default_authz.py +++ /dev/null @@ -1,184 +0,0 @@ -from keycloak import KeycloakOpenID, KeycloakAdmin -import requests -import argparse - - -def init_parser() -> argparse.ArgumentParser: - """ - Init argument parser - :return: ArgumentParser - """ - example_text = '''example: - python delete_client_default_authz.py http://localhost:8080 -a admin -p admin -aR master -c admin-cli -n example_client -rN example - ''' - - arg_parser = argparse.ArgumentParser(epilog=example_text, - formatter_class=argparse.RawDescriptionHelpFormatter) - - arg_parser.add_argument("kc_auth_url", type=str, - default="http://localhost:8080") - - arg_parser.add_argument("-a", "--auth_admin", type=str, - default='admin', help="Auth admin username") - - arg_parser.add_argument("-p", "--auth_admin_password", type=str, - default='admin', help="Auth admin password") - - arg_parser.add_argument("-aR", "--auth_realm", type=str, - default='master', help="Auth realm") - - arg_parser.add_argument("-c", "--client_id", type=str, - default='admin-cli', help="Client id") - - arg_parser.add_argument("-s", "--client_secret", - type=str, default=None, help="Client secret") - - arg_parser.add_argument("-n", "--client_name", type=str, - default=None, help="Client name that should be processed") - - arg_parser.add_argument("-rN", "--realm_name", type=str, - default=None, help="Realm where the client is registered") - - return arg_parser - - -def delete_default_resource(keycloak_admin: KeycloakAdmin, client_id: str): - """ Delete client default resource - - Args: - keycloak_admin (KeycloakAdmin): Keycloak admin client - client_id (str): Client id - """ - resources = keycloak_admin.get_client_authz_resources(client_id) - if resources is not None and len(resources) > 0: - for resource in resources: - if resource is not None and len(resource) > 0 and "name" in resource and resource['name'] == 'Default Resource' and '_id' in resource: - keycloak_admin.delete_client_authz_resource( - client_id=client_id, resource_id=resource['_id']) - - -def delete_default_policy(keycloak_admin: KeycloakAdmin, client_id: str): - """ Delete client default policy - - Args: - keycloak_admin (KeycloakAdmin): Keycloak admin client - client_id (str): Client id - """ - policies = keycloak_admin.get_client_authz_policies(client_id) - if policies is not None and len(policies) > 0: - for policy in policies: - if policy is not None and len(policy) > 0 and "name" in policy and policy['name'] == 'Default Policy' and 'id' in policy: - keycloak_admin.delete_client_authz_policy( - client_id=client_id, policy_id=policy['id']) - - -def delete_default_permission(keycloak_admin: KeycloakAdmin, - realm_name: str, - client_id: str, - token: str, - base_url: str = 'http://localhost:8080'): - """ Delete client default permission - - Args: - keycloak_admin (KeycloakAdmin): Keycloak admin client - realm_name (str): Realm name where client is registered - client_id (str): Client id - token (str): Authorization token - base_url (_type_, optional): Keycloak base url. Defaults to 'http://localhost:8080'. - - Raises: - Exception: _description_ - """ - - permissions = keycloak_admin.get_client_authz_permissions( - client_id=client_id) - - if permissions is not None and len(permissions) > 0: - for permission in permissions: - if permission is not None and len(permission) > 0 and "name" in permission and permission['name'] == 'Default Permission' and 'id' in permission: - url = f"{base_url}/admin/realms/{realm_name}/clients/{client_id}/authz/resource-server/permission/resource/{permission['id']}" - request = requests.delete( - url=url, headers={"Authorization": f"Bearer {token}"}) - if request is None or request.status_code is None or request.status_code != 204: - content = '' - if request is not None and request.content is not None: - content = request.content - raise Exception( - f"Failed to delete {client_id} default permission: {content}") - - -if __name__ == '__main__': - parser = init_parser() - - auth_url = 'http://localhost:8080' - auth_username = 'admin' - auth_password = 'admin' - auth_realm = 'master' - client_id = 'admin-cli' - client_secret = '' - client_name = '' - realm_name = '' - - try: - args = parser.parse_args() - - if args.kc_auth_url: - auth_url = args.kc_auth_url - else: - print(f'Error : {parser.epilog}') - - if args.auth_admin: - auth_username = args.auth_admin - - if args.auth_admin_password: - auth_password = args.auth_admin_password - - if args.auth_realm: - auth_realm = args.auth_realm - - if args.client_id: - client_id = args.client_id - - if args.client_secret: - client_secret = args.client_secret - - if args.realm_name: - realm_name = args.realm_name - else: - print(f'Error realm name is required: {parser.epilog}') - - if args.client_name: - client_name = args.client_name - else: - print(f'Error client name is required: {parser.epilog}') - - # Configure client - keycloak_openid = KeycloakOpenID(server_url=auth_url, - client_id=client_id, - realm_name=auth_realm, - client_secret_key=client_secret) - - token = keycloak_openid.token(auth_username, auth_password) - - admin_client = KeycloakAdmin(server_url=auth_url, - client_id=client_id, - token=token, - realm_name=realm_name) - - id = admin_client.get_client_id(client_name) - - delete_default_resource(admin_client, id) - delete_default_policy(admin_client, id) - delete_default_permission(admin_client, - realm_name, id, - token, - auth_url) - - except argparse.ArgumentError: - print(parser.epilog) - exit(255) - except KeyboardInterrupt: - exit(0) - except Exception as e: - print(f'Error : {e}') - exit(255) diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/common/generate_secrets.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/common/generate_secrets.yml deleted file mode 100644 index 148390b..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/common/generate_secrets.yml +++ /dev/null @@ -1,77 +0,0 @@ -# Generate stack secrets -- name: Generate stack secrets - no_log: true - block: - # Define database credentials - # Dynamically generates and sets database credentials including username, database name, and a secure password. - - set_fact: - db: - username: postgres - db_name: keycloak - password: "{{ lookup('ansible.builtin.password', '/dev/null', chars=['ascii_letters', 'digits'], length=32) | trim }}" - - # Check database user credentials - # Verifies that database credentials are correctly defined and non-empty. - - assert: - that: - - db.username is defined - - db.username != '' - - db.password is defined - - db.password != '' - - db.db_name is defined - - db.db_name != '' - fail_msg: "Database credentials assertion failed" - success_msg: "Ok" - - # Define Keycloak credentials - # Dynamically generates and sets Keycloak credentials, including admin and testing realm details. - - set_fact: - keycloak: - client_id: admin-cli - auth_url: "http://localhost:8080" - auth_realm: "master" - username: admin - password: "{{ lookup('ansible.builtin.password', '/dev/null', chars=['ascii_letters', 'digits'], length=32) | trim }}" - testing_realm_client_id: app_client - testing_realm_auth_client_id: "auth_client" - testing_realm_auth_client_secret: "{{ lookup('ansible.builtin.password', '/dev/null', chars=['ascii_letters', 'digits'], length=32) | trim }}" - testing_realm_admin: - username: "{{ realm }}_admin" - password: "{{ (lookup('ansible.builtin.password', '/dev/null', chars=['ascii_letters', 'digits'], length=32) | trim) ~ '@A1' }}" - email: "admin@example.com" - firstName: "John" - lastName: "Doe" - - # Check Keycloak admin credentials - # Verifies that Keycloak admin and testing realm credentials are correctly defined and non-empty. - - assert: - that: - - keycloak.client_id is defined - - keycloak.client_id != '' - - keycloak.auth_url is defined - - keycloak.auth_url != '' - - keycloak.auth_realm is defined - - keycloak.auth_realm != '' - - keycloak.username is defined - - keycloak.username != '' - - keycloak.password is defined - - keycloak.password != '' - - keycloak.testing_realm_client_id is defined - - keycloak.testing_realm_client_id != '' - - keycloak.testing_realm_auth_client_id is defined - - keycloak.testing_realm_auth_client_id != '' - - keycloak.testing_realm_auth_client_secret is defined - - keycloak.testing_realm_auth_client_secret != '' - - keycloak.testing_realm_admin is defined - - keycloak.testing_realm_admin.username is defined - - keycloak.testing_realm_admin.username != '' - - keycloak.testing_realm_admin.password is defined - - keycloak.testing_realm_admin.password != '' - - keycloak.testing_realm_admin.email is defined - - keycloak.testing_realm_admin.email != '' - - keycloak.testing_realm_admin.firstName is defined - - keycloak.testing_realm_admin.firstName != '' - - keycloak.testing_realm_admin.lastName is defined - - keycloak.testing_realm_admin.lastName != '' - fail_msg: "Keycloak credentials assertion failed" - success_msg: "Ok" diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/database/setup.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/database/setup.yml deleted file mode 100644 index 3be5cc6..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/database/setup.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Wait for database to start -# Ensures that the database service is reachable before proceeding with further tasks. -- name: Wait for database to start - community.postgresql.postgresql_ping: - login_user: "{{ db.username }}" - login_password: "{{ db.password }}" - login_host: 127.0.0.1 - port: 5432 - register: postgres_readiness - until: postgres_readiness.is_available | default(false) - retries: 30 - delay: 2 - -# Create Keycloak database -# Creates the Keycloak database if it does not already exist, ensuring it is ready for use. -- name: Create keycloak database - community.postgresql.postgresql_db: - name: "{{ db.db_name }}" - login_user: "{{ db.username }}" - login_password: "{{ db.password }}" - login_host: 127.0.0.1 - port: 5432 - owner: "{{ db.username }}" - state: present - force: true - when: not (stack_state == 'stopped' or stack_state == 'absent') diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_services_environment.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_services_environment.yml deleted file mode 100644 index 5bfa898..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_services_environment.yml +++ /dev/null @@ -1,38 +0,0 @@ -# Create containers directory -# Ensures the containers directory is created with the specified properties. -- name: Create containers directory - file: - path: "{{ containers_dir }}/volumes/nginx" - state: directory - recurse: true - force: true - -# Generate services environments -# Renders environment configuration files for services using templates and places them in the containers directory. -- name: Generate services environments - template: - src: "templates/{{ item }}.j2" - dest: "{{ containers_dir }}/{{ item }}" - mode: "0600" - loop: - - compose.yml - - keycloak.env - - postgres.env - no_log: true - -# Build JS policies JAR -# Packages JavaScript authorization policies into a JAR for deployment to Keycloak providers. -- name: Build JS policies JAR - archive: - path: "{{ workspace }}/files/js-policies/" - dest: "{{ containers_dir }}/js-policies.jar" - format: zip - force_archive: true - -# Copy nginx configuration to volumes -# Nginx configuration file will be used as fake endpoints during the tests -- name: Copy nginx configuration to volumes - copy: - src: "files/nginx.default.conf" - dest: "{{ containers_dir }}/volumes/nginx/default.conf" - mode: "0644" diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_test_environment.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_test_environment.yml deleted file mode 100644 index 0b5f50a..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/environment/generate_test_environment.yml +++ /dev/null @@ -1,17 +0,0 @@ -# Create test environment directory -# Ensures that the directory for the test environment exists. -- name: Create test environment directory - file: - path: "{{ current_dir }}/Assets" - state: directory - recurse: true - force: true - -# Generate test environment -# Renders the testing environment configuration file from a template and saves it to the specified location. -- name: "Generate test environment" - template: - src: "templates/testing_environment.json.j2" - dest: "{{ current_dir }}/Assets/testing_environment.json" - mode: "0600" - no_log: true diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/client_scopes/attributes.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/client_scopes/attributes.yml deleted file mode 100644 index 5e412aa..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/client_scopes/attributes.yml +++ /dev/null @@ -1,42 +0,0 @@ -# Create attribute scope -- name: "Create realm {{ realm }} attributes client scope" - community.general.keycloak_clientscope: - auth_client_id: "{{ keycloak.client_id }}" - auth_keycloak_url: "{{ keycloak.auth_url }}" - auth_realm: "{{ keycloak.auth_realm }}" - auth_username: "{{ keycloak.username }}" - auth_password: "{{ keycloak.password }}" - realm: "{{ realm }}" - name: attributes - state: present - description: user attributes - protocol: openid-connect - attributes: - include.in.token.scope: 'true' - display.on.consent.screen: 'false' - gui.order: '' - consent.screen.text: '' - protocol_mappers: - - protocol: openid-connect - name: account_owner - protocolMapper: oidc-usermodel-attribute-mapper - config: - aggregate.attrs: 'false' - userinfo.token.claim: 'false' - multivalued: 'false' - user.attribute: account_owner - id.token.claim: 'false' - access.token.claim: 'true' - claim.name: account_owner - - protocol: openid-connect - name: business_account_owner - protocolMapper: oidc-usermodel-attribute-mapper - config: - aggregate.attrs: 'false' - userinfo.token.claim: 'false' - multivalued: 'false' - user.attribute: business_account_owner - id.token.claim: 'false' - access.token.claim: 'true' - claim.name: business_account_owner - retries: 5 \ No newline at end of file diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/client_scopes/audiences.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/client_scopes/audiences.yml deleted file mode 100644 index 23239a0..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/client_scopes/audiences.yml +++ /dev/null @@ -1,27 +0,0 @@ -# Create audiences -# Configures a Keycloak client scope for managing OIDC audiences in the specified realm. -- name: "Create realm {{ realm }} audiences client scope" - community.general.keycloak_clientscope: - auth_client_id: "{{ keycloak.client_id }}" - auth_keycloak_url: "{{ keycloak.auth_url }}" - auth_realm: "{{ keycloak.auth_realm }}" - auth_username: "{{ keycloak.username }}" - auth_password: "{{ keycloak.password }}" - realm: "{{ realm }}" - name: audiences - description: oidc audiences - protocol: openid-connect - attributes: - include.in.token.scope: 'true' - display.on.consent.screen: 'false' - gui.order: '' - consent.screen.text: '' - protocol_mappers: - - name: app_client audience mapper - protocol: openid-connect - protocolMapper: oidc-audience-mapper - config: - included.client.audience: app_client - id.token.claim: 'false' - access.token.claim: 'true' - retries: 5 diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/app_client.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/app_client.yml deleted file mode 100644 index 1d14592..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/app_client.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Set the default client scopes with the "basic" scope included -# If the Keycloak version is not in ['20.0.3', '22.0.3', '21.1.2', '23.0.7'], the "basic" scope is added to the default client scopes. -- set_fact: - app__client_default_client_scopes: - - "acr" - - "attributes" - - "audiences" - - "email" - - "profile" - - "roles" - - "web-origins" - - "basic" - when: "(kc_version | default('')) not in ['20.0.3', '22.0.3', '21.1.2', '23.0.7', '24.0.5-0']" - -# Set the default client scopes without the "basic" scope -# If the Keycloak version is in ['20.0.3', '22.0.3', '21.1.2', '23.0.7'], the "basic" scope is excluded from the default client scopes. -- set_fact: - app__client_default_client_scopes: - - "acr" - - "attributes" - - "audiences" - - "email" - - "profile" - - "roles" - - "web-origins" - when: "(kc_version | default('')) in ['20.0.3', '22.0.3', '21.1.2', '23.0.7', '24.0.5-0']" - - -# Create app client -# Configures an application client in the specified Keycloak realm. -- name: "Create {{ realm }} app client" - community.general.keycloak_client: - auth_client_id: "{{ keycloak.client_id }}" - auth_keycloak_url: "{{ keycloak.auth_url }}" - auth_realm: "{{ keycloak.auth_realm }}" - auth_username: "{{ keycloak.username }}" - auth_password: "{{ keycloak.password }}" - public_client: true - client_authenticator_type: "client-secret" - client_id: app_client - consent_required: false - description: "Application client" - direct_access_grants_enabled: true - enabled: true - frontchannel_logout: true - full_scope_allowed: true - implicit_flow_enabled: false - name: "Application client" - realm: "{{ realm }}" - service_accounts_enabled: false - authorization_services_enabled: false - always_display_in_console: false - standard_flow_enabled: false - default_client_scopes: "{{ app__client_default_client_scopes }}" - # Offline tokens must be explicitly requested so ordinary logins retain online sessions. - optional_client_scopes: - - "offline_access" - - "address" - - "microprofile-jwt" - - "phone" - attributes: - backchannel.logout.session.required: 'true' - client_credentials.use_refresh_token: 'false' - display.on.consent.screen: 'false' - oauth2.device.authorization.grant.enabled: 'false' - backchannel.logout.revoke.offline.tokens: 'false' - use.refresh.tokens: 'true' - exclude.session.state.from.auth.response: 'false' - state: present - retries: 5 diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/auth_client.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/auth_client.yml deleted file mode 100644 index ec762e5..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/clients/auth_client.yml +++ /dev/null @@ -1,267 +0,0 @@ -# Delete auth_client from realm -- name: "Delete auth_client client if it exists from {{ realm }}" - community.general.keycloak_client: - auth_client_id: "{{ keycloak.client_id }}" - auth_keycloak_url: "{{ keycloak.auth_url }}" - auth_realm: "{{ keycloak.auth_realm }}" - auth_username: "{{ keycloak.username }}" - auth_password: "{{ keycloak.password }}" - realm: "{{ realm }}" - client_id: auth_client - state: absent - retries: 5 - -# Create auth_client client -- name: "Create {{ realm }} app client" - community.general.keycloak_client: - auth_client_id: "{{ keycloak.client_id }}" - auth_keycloak_url: "{{ keycloak.auth_url }}" - auth_realm: "{{ keycloak.auth_realm }}" - auth_username: "{{ keycloak.username }}" - auth_password: "{{ keycloak.password }}" - public_client: false - client_authenticator_type: "client-secret" - client_id: auth_client - secret: "{{ keycloak.testing_realm_auth_client_secret }}" - consent_required: false - description: "Authentication service" - direct_access_grants_enabled: true - enabled: true - frontchannel_logout: true - full_scope_allowed: true - implicit_flow_enabled: false - name: "Authentication service" - realm: "{{ realm }}" - service_accounts_enabled: true - authorization_services_enabled: true - always_display_in_console: false - standard_flow_enabled: false - authorization_settings: - allowRemoteResourceManagement: true - policyEnforcementMode: ENFORCING - resources: - - name: Account - type: urn:api:resources:account - ownerManagedAccess: true - displayName: Account endpoint - uris: - - "/api/*/auth/account/*" - scopes: - - name: UpdateEmail - - name: UpdatePassword - - name: Verify - - name: GetVerificationCode - - name: Profile - type: urn:api:resources:profile - ownerManagedAccess: true - displayName: Profile endpoint - uris: - - "/api/*/auth/profile/*" - scopes: - - name: Get - - name: Business - - name: Role - type: urn:api:resources:role - ownerManagedAccess: true - displayName: Role endpoint - uris: - - "/api/*/auth/role/*" - scopes: - - name: Create - - name: Update - - name: List - - name: AvailablePermissions - - name: Assign - - name: Unassign - - name: ChangeOwner - - name: Delete - - name: TwoFactor - type: urn:api:resources:twofactor - ownerManagedAccess: true - displayName: Two factor endpoint - uris: - - "/api/*/auth/twofactor/*" - scopes: - - name: Enable - - name: Verify - - name: Disable - - name: GetRecoveryCodes - policies: - - name: should_be_client_user - description: 'Should be a client user' - type: role - logic: POSITIVE - config: - roles: '[{"id":"kc_client_role_1","required":false},{"id":"kc_client_role_2","required":false},{"id":"kc_client_role_3","required":false}]' - - name: should_be_account_owner - description: 'Should be self account owner' - type: script-should-be-account-owner.js - logic: POSITIVE - - name: should_be_business_account_owner - description: 'Should be business account owner' - type: script-should-be-business-account-owner.js - logic: POSITIVE - - name: can_view_roles - description: 'Can view roles' - type: script-can-view-roles.js - logic: POSITIVE - - name: can_write_roles - description: 'Can write roles' - type: script-can-write-roles.js - logic: POSITIVE - - name: can_delete_roles - description: 'Can delete roles' - type: script-can-delete-roles.js - logic: POSITIVE - - name: should_be_client_owner - description: 'Should have client role and business account owner' - type: aggregate - logic: POSITIVE - decisionStrategy: UNANIMOUS - config: - applyPolicies: '["should_be_client_user","should_be_business_account_owner"]' - - name: should_be_client_role_writer - description: 'Should have client role and can write client roles' - type: aggregate - logic: POSITIVE - decisionStrategy: UNANIMOUS - config: - applyPolicies: '["should_be_client_user","can_write_roles"]' - - name: should_be_client_role_viewer - description: 'Should have client role and can view client roles' - type: aggregate - logic: POSITIVE - decisionStrategy: UNANIMOUS - config: - applyPolicies: '["should_be_client_user","can_view_roles"]' - - name: should_be_client_role_remover - description: 'Should have client role and can delete client roles' - type: aggregate - logic: POSITIVE - decisionStrategy: UNANIMOUS - config: - applyPolicies: '["should_be_client_user","can_delete_roles"]' - - name: permit_users_to_manage_their_accounts - description: Permit users to manager their accounts. - type: scope - logic: POSITIVE - decisionStrategy: UNANIMOUS - config: - scopes: '["UpdatePassword","Verify","GetVerificationCode","UpdateEmail","Verify","Enable","Disable","GetRecoveryCodes"]' - resources: '["Account","TwoFactor"]' - applyPolicies: '["should_be_client_user","should_be_account_owner"]' - - name: permit_users_to_get_their_profiles - description: Permit users to fetch their profiles. - type: scope - logic: POSITIVE - decisionStrategy: UNANIMOUS - config: - scopes: '["Get","Business"]' - resources: '["Profile"]' - applyPolicies: '["should_be_client_user","should_be_account_owner"]' - - name: permit_users_to_view_roles - description: Permit users to list roles and permissions. - type: scope - logic: POSITIVE - decisionStrategy: AFFIRMATIVE - config: - scopes: '["List","AvailablePermissions"]' - resources: '["Role"]' - applyPolicies: '["should_be_client_owner","should_be_client_role_viewer","should_be_client_role_writer","should_be_client_role_remover"]' - - name: permit_users_to_write_roles - description: Permit users to create, update, assign and un-assign roles. - type: scope - logic: POSITIVE - decisionStrategy: AFFIRMATIVE - config: - scopes: '["Create","Update","Assign","Unassign"]' - resources: '["Role"]' - applyPolicies: '["should_be_client_owner","should_be_client_role_writer"]' - - name: permit_users_to_delete_roles - description: Permit users to delete roles. - type: scope - logic: POSITIVE - decisionStrategy: AFFIRMATIVE - config: - scopes: '["Delete"]' - resources: '["Role"]' - applyPolicies: '["should_be_client_owner","should_be_client_role_writer"]' - - name: permit_users_to_change_owner - description: Permit users to change account owner. - type: scope - logic: POSITIVE - decisionStrategy: UNANIMOUS - config: - scopes: '["ChangeOwner"]' - resources: '["Role"]' - applyPolicies: '["should_be_client_owner"]' - scopes: - - name: GetVerificationCode - displayName: Get verification code scope. - - name: UpdateEmail - displayName: Update email scope - - name: UpdatePassword - displayName: Update password scope - - name: Verify - displayName: Verify scope - - name: Business - displayName: Business scope - - name: Create - displayName: Create scope - - name: Update - displayName: Update scope - - name: List - displayName: List scope - - name: AvailablePermissions - displayName: Get available permissions scope - - name: Assign - displayName: Assign scope - - name: Unassign - displayName: Unassign scope - - name: ChangeOwner - displayName: Change owner scope - - name: Delete - displayName: Delete scope - - name: Enable - displayName: Enable scope - - name: Disable - displayName: Disable scope - - name: GetRecoveryCodes - displayName: Get multi factor recovery codes scope - decisionStrategy: UNANIMOUS - attributes: - client.secret.creation.time: 1681353515 - backchannel.logout.session.required: 'true' - client_credentials.use_refresh_token: 'false' - display.on.consent.screen: 'false' - oauth2.device.authorization.grant.enabled: 'false' - backchannel.logout.revoke.offline.tokens: 'false' - use.refresh.tokens: 'true' - exclude.session.state.from.auth.response: 'false' - state: present - register: auth_client_result - retries: 5 - -# Delete auth_client client default authz -- name: "Delete realm {{ realm }} auth_client client default authz" - no_log: true - command: - argv: - - "{{ ansible_playbook_python }}" - - "scripts/delete_client_default_authz.py" - - "{{ keycloak.auth_url }}" - - "-a" - - "{{ keycloak.username }}" - - "-p" - - "{{ keycloak.password }}" - - "-aR" - - "{{ keycloak.auth_realm }}" - - "-c" - - "{{ keycloak.client_id }}" - - "-r" - - "{{ default_admin_roles | join(',')}}" - - "-rN" - - "{{ realm }}" - - "-n" - - "auth_client" - retries: 5 diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/create_realm.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/create_realm.yml deleted file mode 100644 index 8810f49..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/create_realm.yml +++ /dev/null @@ -1,123 +0,0 @@ -# Load realm variables -# Loads Keycloak realm configuration variables from a predefined file. -- include_vars: - file: "vars/kc_realm.yml" - -# Set the old password policy -# This policy uses a higher iteration count (27500) for hashing and includes requirements for length, special characters, uppercase, lowercase, digits, and password history. -- set_fact: - old_password_policy: "hashIterations(27500) and length(10) and notUsername(undefined) and notEmail(undefined) and specialChars(1) and upperCase(1) and lowerCase(1) and digits(1) and passwordHistory(12)" - -# Set the argon password policy -# This policy uses a lower iteration count (99) for hashing while maintaining the same requirements for length, special characters, uppercase, lowercase, digits, and password history. -- set_fact: - argon_password_policy: "hashIterations(99) and length(10) and notUsername(undefined) and notEmail(undefined) and specialChars(1) and upperCase(1) and lowerCase(1) and digits(1) and passwordHistory(12)" - -# Set the active password policy based on the Keycloak version -# If the Keycloak version is not in ['20.0.3', '22.0.3', '21.1.2', '23.0.7'], the argon password policy is used; otherwise, the old password policy is applied. -- set_fact: - password_policy: "{{ argon_password_policy if ((kc_version | default('')) not in ['20.0.3', '22.0.3', '21.1.2', '23.0.7', '24.0.5-0']) else old_password_policy }}" - - -# Create Keycloak realm -# Configures a new Keycloak realm or updates an existing one with the specified settings. -- name: "Create {{ realm }} realm" - community.general.keycloak_realm: - auth_client_id: "{{ keycloak.client_id }}" - auth_keycloak_url: "{{ keycloak.auth_url }}" - auth_realm: "{{ keycloak.auth_realm }}" - auth_username: "{{ keycloak.username }}" - auth_password: "{{ keycloak.password }}" - id: "{{ id }}" - realm: "{{ realm }}" - attributes: "{{ attributes }}" - access_code_lifespan: "{{ access_code_lifespan }}" - access_code_lifespan_login: "{{ access_code_lifespan_login }}" - access_token_lifespan_for_implicit_flow: "{{ access_token_lifespan_for_implicit_flow }}" - action_token_generated_by_admin_lifespan: "{{ action_token_generated_by_admin_lifespan }}" - action_token_generated_by_user_lifespan: "{{ action_token_generated_by_user_lifespan }}" - browser_flow: "{{ browser_flow }}" - client_authentication_flow: "{{ client_authentication_flow }}" - default_signature_algorithm: "{{ default_signature_algorithm }}" - direct_grant_flow: "{{ direct_grant_flow }}" - docker_authentication_flow: "{{ docker_authentication_flow }}" - duplicate_emails_allowed: "{{ duplicate_emails_allowed }}" - edit_username_allowed: "{{ edit_username_allowed }}" - enabled: "{{ enabled }}" - login_with_email_allowed: "{{ login_with_email_allowed }}" - offline_session_idle_timeout: "{{ offline_session_idle_timeout }}" - offline_session_max_lifespan: "{{ offline_session_max_lifespan }}" - offline_session_max_lifespan_enabled: "{{ offline_session_max_lifespan_enabled }}" - password_policy: "{{ password_policy }}" - refresh_token_max_reuse: "{{ refresh_token_max_reuse }}" - registration_allowed: "{{ registration_allowed }}" - registration_email_as_username: "{{ registration_email_as_username }}" - registration_flow: "{{ registration_flow }}" - remember_me: "{{ remember_me }}" - reset_credentials_flow: "{{ reset_credentials_flow }}" - reset_password_allowed: "{{ reset_password_allowed }}" - revoke_refresh_token: "{{ revoke_refresh_token }}" - ssl_required: "{{ ssl_required }}" - sso_session_idle_timeout: "{{ sso_session_idle_timeout }}" - sso_session_idle_timeout_remember_me: "{{ sso_session_idle_timeout_remember_me }}" - sso_session_max_lifespan: "{{ sso_session_max_lifespan }}" - sso_session_max_lifespan_remember_me: "{{ sso_session_max_lifespan_remember_me }}" - user_managed_access_allowed: "{{ user_managed_access_allowed }}" - verify_email: "{{ verify_email }}" - otp_policy_algorithm: "{{ otp_policy_algorithm }}" - otp_policy_digits: "{{ otp_policy_digits }}" - otp_policy_initial_counter: "{{ otp_policy_initial_counter }}" - otp_policy_look_ahead_window: "{{ otp_policy_look_ahead_window }}" - otp_policy_period: "{{ otp_policy_period }}" - otp_policy_type: "{{ otp_policy_type }}" - brute_force_protected: "{{ brute_force_protected }}" - max_failure_wait_seconds: "{{ max_failure_wait_seconds }}" - wait_increment_seconds: "{{ wait_increment_seconds }}" - failure_factor: "{{ failure_factor }}" - permanent_lockout: "{{ permanent_lockout }}" - quick_login_check_milli_seconds: "{{ quick_login_check_milli_seconds }}" - minimum_quick_login_wait_seconds: "{{ minimum_quick_login_wait_seconds }}" - browser_security_headers: "{{ browser_security_headers }}" - events_enabled: "{{ events_enabled }}" - events_expiration: "{{ events_expiration }}" - admin_events_enabled: "{{ admin_events_enabled }}" - admin_events_details_enabled: "{{ admin_events_details_enabled }}" - enabled_event_types: "{{ enabled_event_types }}" - events_listeners: "{{ events_listeners }}" - state: "{{ state }}" - retries: 5 - -# Obtain admin access token for enabling organizations -# Retrieves an admin token from the master realm for API calls that are not supported by the Ansible Keycloak modules. -- name: "Obtain admin access token for {{ realm }} realm configuration" - no_log: true - uri: - url: "{{ keycloak.auth_url }}/realms/{{ keycloak.auth_realm }}/protocol/openid-connect/token" - method: POST - body_format: form-urlencoded - body: - client_id: "{{ keycloak.client_id }}" - username: "{{ keycloak.username }}" - password: "{{ keycloak.password }}" - grant_type: password - status_code: 200 - register: keycloak_admin_token - retries: 5 - when: "(kc_version | default('')) not in ['20.0.3', '22.0.3', '21.1.2', '23.0.7', '24.0.5-0', '25.0.6']" - -# Enable organizations for the realm -# Organizations are available starting from Keycloak 26 and must be enabled at the realm level. -- name: "Enable organizations for {{ realm }} realm" - no_log: true - uri: - url: "{{ keycloak.auth_url }}/admin/realms/{{ realm }}" - method: PUT - headers: - Authorization: "Bearer {{ keycloak_admin_token.json.access_token }}" - Content-Type: "application/json" - body_format: json - body: - organizationsEnabled: true - status_code: 204 - retries: 5 - when: "(kc_version | default('')) not in ['20.0.3', '22.0.3', '21.1.2', '23.0.7', '24.0.5-0', '25.0.6']" diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/set_realm_user_profile_attributes.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/set_realm_user_profile_attributes.yml deleted file mode 100644 index 398f61b..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/set_realm_user_profile_attributes.yml +++ /dev/null @@ -1,105 +0,0 @@ -# Set user profile attributes for a Keycloak realm -# Description: This task configures user profile attributes and permissions for the specified Keycloak realm. -# Attributes include username, email, firstName, lastName, and custom attributes like account_owner and business_account_owner. -# Attributes are categorized into groups for better organization, such as user metadata and user permissions. -- name: "Set {{ realm }} realm user profile attributes" - community.general.keycloak_userprofile: - auth_client_id: "{{ keycloak.client_id }}" - auth_keycloak_url: "{{ keycloak.auth_url }}" - auth_realm: "{{ keycloak.auth_realm }}" - auth_username: "{{ keycloak.username }}" - auth_password: "{{ keycloak.password }}" - realm: "{{ realm }}" - state: present - config: - kc_user_profile_config: - - attributes: - - name: username - displayName: "${username}" - validations: - length: - min: 3 - max: 255 - permissions: - view: - - admin - - user - edit: - - admin - - user - multivalued: false - - name: email - displayName: "${email}" - validations: - email: {} - length: - max: 255 - required: - roles: - - user - permissions: - view: - - admin - - user - edit: - - admin - - user - multivalued: false - - name: firstName - displayName: "${firstName}" - validations: - length: - max: 255 - required: - roles: - - user - permissions: - view: - - admin - - user - edit: - - admin - - user - multivalued: false - - name: lastName - displayName: "${lastName}" - validations: - length: - max: 255 - required: - roles: - - user - permissions: - view: - - admin - - user - edit: - - admin - - user - multivalued: false - - name: account_owner - displayName: "Account owner" - required: - roles: - - user - permissions: - edit: - - admin - view: - - admin - multivalued: false - - name: business_account_owner - displayName: "Business account owner" - required: - roles: - - user - permissions: - edit: - - admin - view: - - admin - multivalued: false - - groups: - - name: "user-metadata" - displayHeader: "User metadata" - displayDescription: "Attributes, which refer to user metadata" \ No newline at end of file diff --git a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/setup.yml b/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/setup.yml deleted file mode 100644 index ba807da..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/tasks/keycloak/setup.yml +++ /dev/null @@ -1,105 +0,0 @@ -# Wait for a working discovery endpoint rather than assuming startup takes 30 seconds. -- name: Wait for keycloak to start - uri: - url: "{{ keycloak.auth_url }}/realms/{{ keycloak.auth_realm }}/.well-known/openid-configuration" - status_code: 200 - timeout: 5 - register: keycloak_discovery - until: keycloak_discovery.status | default(0) == 200 - retries: 60 - delay: 2 - -# Create realm -# Includes the task file to create a Keycloak realm. -- include_tasks: - file: "tasks/keycloak/create_realm.yml" - -# Set user profile attributes for a Keycloak realm -# Includes the task file to set user profile attributes for a Keycloak realm. -# This task is included only for the keycloak version upper to 23 -- include_tasks: - file: "tasks/keycloak/set_realm_user_profile_attributes.yml" - when: "(kc_version | default('')) not in ['20.0.3', '22.0.3', '21.1.2', '23.0.7']" - -# Create realm admin user -# Creates an admin user for the specified realm with the provided credentials. -- name: "Create {{ realm }} realm admin user" - community.general.keycloak_user: - auth_client_id: "{{ keycloak.client_id }}" - auth_keycloak_url: "{{ keycloak.auth_url }}" - auth_realm: "{{ keycloak.auth_realm }}" - auth_username: "{{ keycloak.username }}" - auth_password: "{{ keycloak.password }}" - realm: "{{ realm }}" - username: "{{ keycloak.testing_realm_admin.username }}" - firstName: "{{ keycloak.testing_realm_admin.firstName }}" - lastName: "{{ keycloak.testing_realm_admin.lastName }}" - email: "{{ keycloak.testing_realm_admin.email }}" - enabled: true - emailVerified: false - credentials: - - type: password - value: "{{ keycloak.testing_realm_admin.password }}" - temporary: false - state: present - force: false - retries: 5 - when: realm is defined - -# Assign roles to realm admin user -# Assigns default roles to the admin user in the specified realm using a Python script. -- name: "Assign realm {{ realm }} admin roles" - no_log: true - command: - argv: - - "{{ ansible_playbook_python }}" - - "scripts/assign_admin_roles.py" - - "{{ keycloak.auth_url }}" - - "-a" - - "{{ keycloak.username }}" - - "-p" - - "{{ keycloak.password }}" - - "-aR" - - "{{ keycloak.auth_realm }}" - - "-c" - - "{{ keycloak.client_id }}" - - "-r" - - "{{ default_admin_roles | join(',')}}" - - "-rN" - - "{{ realm }}" - - "-u" - - "{{ keycloak.testing_realm_admin.username }}" - retries: 5 - when: realm is defined and default_admin_roles is defined and default_admin_roles | length > 0 - -# Create realm roles -# Configures roles for the specified realm, iterating through the provided list of roles. -- name: Create realm roles - community.general.keycloak_role: - auth_client_id: "{{ keycloak.client_id }}" - auth_keycloak_url: "{{ keycloak.auth_url }}" - auth_realm: "{{ keycloak.auth_realm }}" - auth_username: "{{ keycloak.username }}" - auth_password: "{{ keycloak.password }}" - realm: "{{ realm }}" - name: "{{ item }}" - state: present - description: "{{ item | replace('_',' ') | capitalize }} role" - loop: "{{ realm_roles }}" - retries: 5 - when: realm_roles is defined and realm_roles | length > 0 - -# Create client scopes and audiences -# Includes the task file to configure client scopes and audiences in Keycloak. -- include_tasks: - file: "tasks/keycloak/client_scopes/{{ item }}.yml" - loop: - - "audiences" - - "attributes" - -# Create realm clients -# Includes the task file to configure Keycloak clients for the specified realm. -- include_tasks: - file: "{{ item }}" - with_fileglob: - - "tasks/keycloak/clients/*.yml" diff --git a/NETCore.Keycloak.Client.Tests/ansible/templates/compose.yml.j2 b/NETCore.Keycloak.Client.Tests/ansible/templates/compose.yml.j2 deleted file mode 100644 index 0fbd599..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/templates/compose.yml.j2 +++ /dev/null @@ -1,51 +0,0 @@ -services: - net_core_keycloak_postgres: - container_name: net_core_keycloak_postgres - image: postgres:16 - env_file: - - postgres.env - volumes: - - net_core_keycloak_tests_postgres:/data/postgres - ports: - - "127.0.0.1:5432:5432" - networks: - - net_core_keycloak_tests_network - restart: on-failure - - net_core_keycloak_idp: - container_name: net_core_keycloak_idp - image: "quay.io/keycloak/keycloak:{{ keycloak_version}}" -{% if kc_version in ['20.0.3', '22.0.3', '21.1.2', '23.0.7', '24.0.5-0'] %} - command: ["start-dev", "--spi-connections-jpa-legacy-migration-strategy=update"] -{% elif kc_version in ['25.0.6'] %} - command: ["start-dev"] -{% else %} - command: ["start-dev"] -{% endif %} - env_file: - - keycloak.env - volumes: - - "{{ containers_dir }}/js-policies.jar:/opt/keycloak/providers/js-policies.jar:ro" - ports: - - "127.0.0.1:8080:8080" - networks: - - net_core_keycloak_tests_network - restart: on-failure - - net_core_keycloak_nginx: - container_name: net_core_keycloak_nginx - image: "nginx" - ports: - - "127.0.0.1:8899:80" - networks: - - net_core_keycloak_tests_network - volumes: - - "{{ containers_dir }}/volumes/nginx/:/etc/nginx/conf.d" - restart: on-failure - -networks: - net_core_keycloak_tests_network: - driver: bridge - -volumes: - net_core_keycloak_tests_postgres: diff --git a/NETCore.Keycloak.Client.Tests/ansible/templates/keycloak.env.j2 b/NETCore.Keycloak.Client.Tests/ansible/templates/keycloak.env.j2 deleted file mode 100644 index 3e373a4..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/templates/keycloak.env.j2 +++ /dev/null @@ -1,23 +0,0 @@ -{% if kc_version in ['20.0.3', '22.0.3', '21.1.2', '23.0.7'] %} -KC_FEATURES=declarative-user-profile,scripts,client-policies -{% elif kc_version in ['24.0.5-0', '25.0.6'] %} -KC_FEATURES=scripts,client-policies -{% else %} -KC_FEATURES=scripts,client-policies,admin-fine-grained-authz:{{ kc_fgap_version | default('v1') }},organization -{% endif %} - -KC_DB=postgres -KC_DB_USERNAME={{ db.username | default('') }} -KC_DB_PASSWORD={{ db.password | default('') }} -KC_DB_URL=jdbc:postgresql://net_core_keycloak_postgres:5432/{{ db.db_name | default('') }} - -KC_HEALTH_ENABLED=true -KC_METRICS_ENABLED=true - -{% if kc_version in ['20.0.3', '22.0.3', '21.1.2', '23.0.7', '24.0.5-0', '25.0.6'] %} -KEYCLOAK_ADMIN={{ keycloak.username | default('') }} -KEYCLOAK_ADMIN_PASSWORD={{ keycloak.password | default('') }} -{% else %} -KC_BOOTSTRAP_ADMIN_USERNAME={{ keycloak.username | default('') }} -KC_BOOTSTRAP_ADMIN_PASSWORD={{ keycloak.password | default('') }} -{% endif %} diff --git a/NETCore.Keycloak.Client.Tests/ansible/templates/postgres.env.j2 b/NETCore.Keycloak.Client.Tests/ansible/templates/postgres.env.j2 deleted file mode 100644 index 752f73e..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/templates/postgres.env.j2 +++ /dev/null @@ -1,3 +0,0 @@ -POSTGRES_USER={{ db.username | default('') }} -POSTGRES_PASSWORD={{ db.password | default('') }} -PGDATA=/data/postgres \ No newline at end of file diff --git a/NETCore.Keycloak.Client.Tests/ansible/templates/testing_environment.json.j2 b/NETCore.Keycloak.Client.Tests/ansible/templates/testing_environment.json.j2 deleted file mode 100644 index 076a7b7..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/templates/testing_environment.json.j2 +++ /dev/null @@ -1,33 +0,0 @@ -{ - "kc_version": "{{ keycloak_version }}", - "baseUrl": "{{ keycloak.auth_url }}", - "invalidBaseUrl": "http://localhost:8899", - "auth_realm": "{{ keycloak.auth_realm }}", - "client_id": "{{ keycloak.client_id }}", - "admin":{ - "username": "{{ keycloak.username }}", - "password": "{{ keycloak.password }}" - }, - "testing_realm": { - "name": "{{ realm }}", - "public_client": { - "client_id": "{{ keycloak.testing_realm_client_id }}" - }, - "private_client": { - "client_id": "{{ keycloak.testing_realm_auth_client_id }}", - "client_secret": "{{ keycloak.testing_realm_auth_client_secret }}" - }, - "user": { - "username": "{{ keycloak.testing_realm_admin.username }}", - "password": "{{ keycloak.testing_realm_admin.password }}", - "permissions": [ - { - "name" : "auth_client", - "scopes": [ - "Profile#Get" - ] - } - ] - } - } -} \ No newline at end of file diff --git a/NETCore.Keycloak.Client.Tests/ansible/utils/extra/pre_flight.yml b/NETCore.Keycloak.Client.Tests/ansible/utils/extra/pre_flight.yml deleted file mode 100644 index a98903a..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/utils/extra/pre_flight.yml +++ /dev/null @@ -1,41 +0,0 @@ -# Check target platforms -- name: "Check OS version and family" - assert: - that: ansible_facts['distribution'] in supported_distribution - fail_msg: "This can only be run against Supported OSs. {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_major_version'] }} is not supported." - success_msg: "This is running against a supported OS {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_major_version'] }}" - changed_when: false - tags: - - always - -- name: "Validate the fine-grained admin permission profile" - assert: - that: kc_fgap_version | default('v1') in ['v1', 'v2'] - fail_msg: "kc_fgap_version must be v1 or v2" - changed_when: false - -# Check keycloak version -- name: "Check keycloak version" - assert: - that: kc_version in supported_keycloak_version - fail_msg: "This can only provision testing environment on supported keycloak versions. {{ kc_version }} is not supported." - success_msg: "Provision testing environment a supported keycloak version {{ kc_version }}" - changed_when: false - when: (kc_version | default('')) != '' - tags: - - always - -# Set rhel as a target -- set_fact: - target_platform: rhel - when: ansible_facts['distribution'] == 'CentOS' or ansible_facts['os_family'] == 'RedHat' or ansible_facts['os_family'] == "Rocky" or ansible_facts['os_family'] == "AlmaLinux" - -# Set debian as a target -- set_fact: - target_platform: debian - when: ansible_facts['distribution'] == 'Ubuntu' or ansible_facts['os_family'] == 'Debian' - -# Set Keycloak version -# Sets the Keycloak version to the value provided in 'kc_version', or defaults to '22.0.3' if none is provided. -- set_fact: - keycloak_version: "{{ kc_version | default('20.0.3') }}" diff --git a/NETCore.Keycloak.Client.Tests/ansible/vars/kc_realm.yml b/NETCore.Keycloak.Client.Tests/ansible/vars/kc_realm.yml deleted file mode 100644 index b35d3cc..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/vars/kc_realm.yml +++ /dev/null @@ -1,98 +0,0 @@ -# Net core keycloak tests realm configuration -id: net_core_keycloak_tests -realm: net_core_keycloak_tests -attributes: - cibaBackchannelTokenDeliveryMode: poll - cibaExpiresIn: '120' - cibaAuthRequestedUserHint: login_hint - oauth2DeviceCodeLifespan: '600' - oauth2DevicePollingInterval: '5' - clientOfflineSessionMaxLifespan: '0' - clientSessionIdleTimeout: '0' - parRequestUriLifespan: '60' - clientSessionMaxLifespan: '0' - clientOfflineSessionIdleTimeout: '0' - cibaInterval: '5' - realmReusableOtpCode: 'false' -access_code_lifespan: 60 -access_code_lifespan_login: 1800 -access_token_lifespan_for_implicit_flow: 300 -action_token_generated_by_admin_lifespan: 43200 -action_token_generated_by_user_lifespan: 300 -browser_flow: "browser" -client_authentication_flow: "clients" -default_signature_algorithm: "PS512" -direct_grant_flow: "direct grant" -docker_authentication_flow: "docker auth" -duplicate_emails_allowed: false -edit_username_allowed: true -enabled: true -login_with_email_allowed: true -offline_session_idle_timeout: 1800 -offline_session_max_lifespan: 1800 -offline_session_max_lifespan_enabled: true -password_policy: "hashIterations(27500) and length(10) and notUsername(undefined) and notEmail(undefined) and specialChars(1) and upperCase(1) and lowerCase(1) and digits(1) and passwordHistory(12)" -refresh_token_max_reuse: 0 -registration_allowed: false -registration_email_as_username: false -registration_flow: "registration" -remember_me: false -reset_credentials_flow: "reset credentials" -reset_password_allowed: false -revoke_refresh_token: false -ssl_required: "external" -sso_session_idle_timeout: 43200 -sso_session_idle_timeout_remember_me: 0 -sso_session_max_lifespan: 36000 -sso_session_max_lifespan_remember_me: 0 -user_managed_access_allowed: true -verify_email: false -otp_policy_algorithm: "HmacSHA1" -otp_policy_digits: 6 -otp_policy_initial_counter: 0 -otp_policy_look_ahead_window: 1 -otp_policy_period: 30 -otp_policy_type: "totp" -brute_force_protected: false -max_failure_wait_seconds: 900 -wait_increment_seconds: 60 -failure_factor: 30 -permanent_lockout: true -quick_login_check_milli_seconds: 1000 -minimum_quick_login_wait_seconds: 60 -browser_security_headers: - contentSecurityPolicyReportOnly: '' - xContentTypeOptions: nosniff - referrerPolicy: no-referrer - xRobotsTag: none - xFrameOptions: SAMEORIGIN - contentSecurityPolicy: frame-src 'self'; frame-ancestors 'self'; object-src 'none'; - xXSSProtection: 1; mode=block - strictTransportSecurity: max-age=31536000; includeSubDomains -events_enabled: true -events_expiration: 3600 -admin_events_enabled: true -admin_events_details_enabled: true -enabled_event_types: -- "LOGIN" -- "LOGOUT" -events_listeners: -- "jboss-logging" -state: present -realm_roles: -- kc_client_role_1 -- kc_client_role_2 -- kc_client_role_3 -default_admin_roles: -- "query-realms" -- "manage-authorization" -- "view-users" -- "manage-clients" -- "view-authorization" -- "query-groups" -- "view-realm" -- "manage-users" -- "query-clients" -- "realm-admin" -- "view-clients" -- "query-users" diff --git a/NETCore.Keycloak.Client.Tests/ansible/vars/main.yml b/NETCore.Keycloak.Client.Tests/ansible/vars/main.yml deleted file mode 100644 index 4050baf..0000000 --- a/NETCore.Keycloak.Client.Tests/ansible/vars/main.yml +++ /dev/null @@ -1,68 +0,0 @@ -# Current working directory -# Dynamically sets the current directory using the PWD environment variable. -current_dir: "{{ lookup('env', 'PWD') }}" - -# Workspace directory -# Defines the workspace directory used for Ansible-related tasks. -# It is derived from the current directory. -workspace: "{{ current_dir }}/ansible" - -# Containers directory -# Specifies the directory where container-related files and configurations are stored. -# This directory is derived from the workspace path. -containers_dir: "{{ workspace }}/containers" - -# Services state -# Manages the state of Docker Compose stacks. -# Accepted values align with the 'state' parameter of the community.docker.docker_compose_v2 module: -# - present: Ensures the stack is up and running. -# - absent: Ensures the stack is removed. -stack_state: "" - -# Database credentials -# Holds dynamically generated database credentials during playbook execution. -# Includes sensitive information required for database operations. -db: {} - -# Keycloak credentials -# Holds dynamically generated Keycloak credentials during playbook execution. -# Includes sensitive information such as admin username, password, or client secrets. -keycloak: {} - -# Keycloak version -# Specifies the version of Keycloak to use for deployment or configuration. -# Example values: "18.0.0", "latest". -kc_version: "" - -# Keep legacy management-permission endpoints available for the complete suite. -# The dedicated v2 regression profile validates the supported FGAP v2 authorization model. -kc_fgap_version: "v1" - -# Target platform -# Specifies the operating system platform for playbook execution. -# This variable is set during pre-audit tasks and is used for platform-specific configurations. -target_platform: "" - -# Supported platforms -# Lists the Linux distributions supported by the playbook. -# Ensure the target host's distribution matches one of these values to prevent compatibility issues. -supported_distribution: -- CentOS -- RedHat -- Rocky -- AlmaLinux -- Ubuntu -- Debian - -# Supported Keycloak versions -# Lists the Keycloak versions supported by the playbook. -# Ensure the target environment's Keycloak version matches one of these values to prevent compatibility issues. -supported_keycloak_version: -- "20.0.3" -- "21.1.2" -- "22.0.3" -- "23.0.7" -- "24.0.5-0" -- "25.0.6" -- "26.5.6" -- "26.8.1" diff --git a/NETCore.Keycloak.Client.Tests/build_test_analyse.cake b/NETCore.Keycloak.Client.Tests/build_test_analyse.cake index 043bc47..e071b61 100644 --- a/NETCore.Keycloak.Client.Tests/build_test_analyse.cake +++ b/NETCore.Keycloak.Client.Tests/build_test_analyse.cake @@ -22,14 +22,6 @@ Task("Test") { Information("Running tests with dotCover..."); - // Ensure dotCover is installed - var dotCoverPath = Context.Tools.Resolve("dotCover"); - if (dotCoverPath == null) - { - Error("dotCover is not installed or cannot be found."); - Environment.Exit(255); - } - // Resolve solution path to absolute for dotCover compatibility var slnPath = MakeAbsolute(new FilePath($"{slnContext}/NETCore.Keycloak.slnx")); @@ -46,13 +38,12 @@ Task("Test") }; // Run tests with dotCover - var result = StartProcess(dotCoverPath, processSettings, out var output, out var error); + var result = StartProcess("dotCover", processSettings, out var output, out var error); // Evaluate the result of the process execution if (result != 0) { - Error("Unit tests failed with dotCover. Error:\n{0}", string.Join(Environment.NewLine, error)); - Environment.Exit(255); + throw new Exception("Unit tests failed with dotCover. Error:\n" + string.Join(Environment.NewLine, error)); } Information("Unit tests executed successfully with dotCover."); @@ -69,11 +60,11 @@ Task("BuildTestAnalyse") { Information("Executing Keycloak client build, test, and analysis..."); - // Generate a list of supported Keycloak versions from 20 to 26 - var versions = Enumerable.Range(20, 26 - 20 + 1).ToList(); + // The managed integration fixture targets the pinned Keycloak 26 release. + var versions = new List { 26 }; // Get the major version argument if provided - var kcMajorVersion = Argument("kc_major_version", null); + var kcMajorVersion = Argument("kc_major_version", 26); // Validate the provided version if (!kcMajorVersion.HasValue || !versions.Contains(kcMajorVersion.Value)) @@ -96,14 +87,25 @@ Task("BuildTestAnalyse") Information($"Processing Keycloak Version: {kcMajorVersion.Value}"); // Set environment variables for Keycloak and SonarQube - Environment.SetEnvironmentVariable("KC_TEST_VERSION", $"prepare_keycloak_{kcMajorVersion.Value}_environment"); Environment.SetEnvironmentVariable("KC_SONAR_TOKEN", sonarToken); - // Execute the required setup, testing, and analysis tasks RunTarget("Setup-Testing-Environment"); - RunTarget("SonarBegin"); - RunTarget("Test"); - RunTarget("SonarEnd"); + try + { + RunTarget("SonarBegin"); + RunTarget("Test"); + RunTarget("SonarEnd"); + } + finally + { + var result = StartProcess("dotnet", new ProcessSettings + { + Arguments = new ProcessArgumentBuilder().Append("run --file") + .AppendQuoted(MakeAbsolute(File("../tools/KeycloakTestEnvironment.cs")).FullPath) + .Append("-- down") + }); + if (result != 0) throw new Exception("Cannot stop the managed test environment."); + } }); // Execute the BuildTestAnalyse task diff --git a/NETCore.Keycloak.Client.Tests/cakeScripts/check_tools.cake b/NETCore.Keycloak.Client.Tests/cakeScripts/check_tools.cake index ae8cb26..6aead6e 100644 --- a/NETCore.Keycloak.Client.Tests/cakeScripts/check_tools.cake +++ b/NETCore.Keycloak.Client.Tests/cakeScripts/check_tools.cake @@ -1,73 +1,28 @@ -/// -/// Task to check if required tools are installed on the system. -/// Iterates over a list of tools, resolves their paths, executes the `--version` command, -/// and captures the output or errors for validation. -/// +/// Check the .NET SDK and Docker Compose v2 through the system PATH. Task("Check-Tools") .Does(() => { - // List of tools to check with their respective version commands var tools = new Dictionary { - { "make", "--version" }, - { "docker-compose", "--version" } + { "dotnet", "--version" }, + { "docker", "compose version" } }; - var index = 0; - foreach (var tool in tools) { - var toolName = tool.Key; - var toolCommand = tool.Value; - - try + var settings = new ProcessSettings { - // Resolve the path to the tool executable - FilePath toolPath = Context.Tools.Resolve(toolName); - - // Configure process settings to execute the version command - var processSettings = new ProcessSettings - { - Arguments = new ProcessArgumentBuilder().Append(toolCommand), - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - // Execute the process and capture output and error streams - var result = StartProcess(toolPath, processSettings, out var output, out var error); - - // Evaluate the result of the process execution - if (result == 0) - { - // Log the captured output if the command executed successfully - Information( - index == 0 - ? "{0} is installed. Version info:\n{1}" - : "\n{0} is installed. Version info:\n{1}", - toolName, - string.Join(Environment.NewLine, output) - ); - } - else - { - // Log the error message and throw an exception if the command failed - Error( - index == 0 - ? "{0} is not installed or not accessible. Error:\n{1}" - : "\n{0} is not installed or not accessible. Error:\n{1}", - toolName, - string.Join(Environment.NewLine, error) - ); - Environment.Exit(255); - } - } - catch (Exception ex) + Arguments = new ProcessArgumentBuilder().Append(tool.Value), + RedirectStandardOutput = true, + RedirectStandardError = true + }; + // Tools.Resolve only checks explicitly registered Cake tools; StartProcess also uses PATH. + var result = StartProcess(tool.Key, settings, out var output, out var error); + if (result != 0) { - // Handle cases where the tool is not found or other exceptions occur - Error("Failed to check {0}. Exception: {1}", toolName, ex.Message); - Environment.Exit(255); + throw new Exception("Cannot run " + tool.Key + ": " + string.Join(Environment.NewLine, error)); } - index++; + Information("{0}: {1}", tool.Key, string.Join(Environment.NewLine, output)); } }); diff --git a/NETCore.Keycloak.Client.Tests/cakeScripts/setup_keycloak_test_environment.cake b/NETCore.Keycloak.Client.Tests/cakeScripts/setup_keycloak_test_environment.cake index 8e005e9..21bc552 100644 --- a/NETCore.Keycloak.Client.Tests/cakeScripts/setup_keycloak_test_environment.cake +++ b/NETCore.Keycloak.Client.Tests/cakeScripts/setup_keycloak_test_environment.cake @@ -1,34 +1,15 @@ -/// -/// Task to set up the testing environment by executing a `make` command. -/// Resolves the `make` executable, processes the specified parameter for the task, -/// and captures the output and error streams for evaluation. -/// +/// Provision the pinned Keycloak fixture with the .NET 10 file-based application. Task("Setup-Testing-Environment") + .IsDependentOn("Check-Tools") .Does(() => { - // Resolve the path to the `make` executable - FilePath makePath = Context.Tools.Resolve("make"); - - // Access the parameter defined in the main file or use a default value - var testingVersion = Environment.GetEnvironmentVariable("KC_TEST_VERSION") ?? "prepare_keycloak_20_environment"; - - // Configure process settings to execute the `make` command - var processSettings = new ProcessSettings - { - Arguments = new ProcessArgumentBuilder() - .Append(testingVersion), // Use the provided parameter - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - // Execute the process and capture output and error streams - var result = StartProcess(makePath, processSettings, out var output, out var error); - - // Evaluate the result of the process execution + var arguments = new ProcessArgumentBuilder() + .Append("run --file") + .AppendQuoted(MakeAbsolute(File("../tools/KeycloakTestEnvironment.cs")).FullPath) + .Append("-- up"); + var result = StartProcess("dotnet", new ProcessSettings { Arguments = arguments }); if (result != 0) { - // Log the error message and throw an exception if the command failed - Error("Cannot prepare testing environment. Error:\n{0}", string.Join(Environment.NewLine, error)); - Environment.Exit(255); + throw new Exception("Cannot prepare the managed Keycloak environment. Run the native tool with down before retrying."); } - }); \ No newline at end of file + }); diff --git a/NETCore.Keycloak.Client.Tests/cakeScripts/sonar_analysis.cake b/NETCore.Keycloak.Client.Tests/cakeScripts/sonar_analysis.cake index 57b4ac5..1df9ac5 100644 --- a/NETCore.Keycloak.Client.Tests/cakeScripts/sonar_analysis.cake +++ b/NETCore.Keycloak.Client.Tests/cakeScripts/sonar_analysis.cake @@ -12,19 +12,15 @@ Task("SonarBegin") if (string.IsNullOrWhiteSpace(sonarToken)) { // Ensure the token is provided before proceeding - Error("Sonar token is required."); - Environment.Exit(255); + throw new Exception("Sonar token is required."); } - // Resolve the path to the `dotnet` executable - FilePath dotnetPath = Context.Tools.Resolve("dotnet"); - // Configure process settings for executing the SonarScanner command var processSettings = new ProcessSettings { Arguments = new ProcessArgumentBuilder() .Append("sonarscanner begin") - .Append($"/d:sonar.token={sonarToken}") + .AppendSecret($"/d:sonar.token={sonarToken}") .Append("/k:Black-Cockpit_NETCore.Keycloak") .Append("/o:black-cockpit") .Append("/d:sonar.host.url=\"https://sonarcloud.io\"") @@ -37,7 +33,11 @@ Task("SonarBegin") }; // Execute the SonarScanner command - StartProcess(dotnetPath, processSettings); + var result = StartProcess("dotnet", processSettings, out var output, out var error); + if (result != 0) + { + throw new Exception("Sonar analysis initialization failed. Error:\n" + string.Join(Environment.NewLine, error)); + } }); /// @@ -55,31 +55,26 @@ Task("SonarEnd") if (string.IsNullOrWhiteSpace(sonarToken)) { // Ensure the token is provided before proceeding - Error("Sonar token is required."); - Environment.Exit(255); + throw new Exception("Sonar token is required."); } - // Resolve the path to the `dotnet` executable - FilePath dotnetPath = Context.Tools.Resolve("dotnet"); - // Configure process settings for executing the SonarScanner command var processSettings = new ProcessSettings { Arguments = new ProcessArgumentBuilder() .Append("sonarscanner end") - .Append($"/d:sonar.token={sonarToken}"), + .AppendSecret($"/d:sonar.token={sonarToken}"), RedirectStandardOutput = true, RedirectStandardError = true }; // Execute the SonarScanner command and capture output/error streams - var result = StartProcess(dotnetPath, processSettings, out var output, out var error); + var result = StartProcess("dotnet", processSettings, out var output, out var error); // Evaluate the result of the process execution if (result != 0) { // Log the error message and exit if the command failed - Error("Sonar analysis finalization failed. Error:\n{0}", string.Join(Environment.NewLine, error)); - Environment.Exit(255); + throw new Exception("Sonar analysis finalization failed. Error:\n" + string.Join(Environment.NewLine, error)); } }); diff --git a/NETCore.Keycloak.Client.Tests/e2e_test.cake b/NETCore.Keycloak.Client.Tests/e2e_test.cake index 4cbb01b..cb89ba4 100644 --- a/NETCore.Keycloak.Client.Tests/e2e_test.cake +++ b/NETCore.Keycloak.Client.Tests/e2e_test.cake @@ -37,42 +37,26 @@ Task("E2E-Tests") { Information("Executing Keycloak client E2E tests..."); - // Generate a list of supported Keycloak versions from 20 to 26 - var versions = Enumerable.Range(20, 26 - 20 + 1).ToList(); - - // Get the major version argument if provided - var kcMajorVersion = Argument("kc_major_version", null); - - // Check if a specific version is provided - if (kcMajorVersion.HasValue) + var kcMajorVersion = Argument("kc_major_version", 26); + if (kcMajorVersion != 26) { - // Validate the version - if (!versions.Contains(kcMajorVersion.Value)) - { - Error($"Invalid Keycloak version: {kcMajorVersion.Value}. Supported versions: {string.Join(", ", versions)}"); - Environment.Exit(255); - } - - // Process the specific version - Information($"Processing specific Keycloak Version: {kcMajorVersion.Value}"); - - Environment.SetEnvironmentVariable("KC_TEST_VERSION", $"prepare_keycloak_{kcMajorVersion.Value}_environment"); + throw new Exception("The managed integration fixture targets Keycloak 26.8.1; use --kc_major_version=26."); + } - RunTarget("Setup-Testing-Environment"); + RunTarget("Setup-Testing-Environment"); + try + { RunTarget("Test"); } - else + finally { - // Process all supported versions - foreach (var version in versions) + var result = StartProcess("dotnet", new ProcessSettings { - Information($"Processing Keycloak Version: {version}"); - - Environment.SetEnvironmentVariable("KC_TEST_VERSION", $"prepare_keycloak_{version}_environment"); - - RunTarget("Setup-Testing-Environment"); - RunTarget("Test"); - } + Arguments = new ProcessArgumentBuilder().Append("run --file") + .AppendQuoted(MakeAbsolute(File("../tools/KeycloakTestEnvironment.cs")).FullPath) + .Append("-- down") + }); + if (result != 0) throw new Exception("Cannot stop the managed test environment."); } }); diff --git a/NETCore.Keycloak.Client.Tests/inventory.ini b/NETCore.Keycloak.Client.Tests/inventory.ini deleted file mode 100644 index 733f163..0000000 --- a/NETCore.Keycloak.Client.Tests/inventory.ini +++ /dev/null @@ -1 +0,0 @@ -localhost ansible_connection=local ansible_python_interpreter="{{ lookup('env','PWD') }}/{{ lookup('env','VIRTUAL_ENV_DIR') }}/bin/python" diff --git a/NETCore.Keycloak.Client.Tests/requirements-report.in b/NETCore.Keycloak.Client.Tests/requirements-report.in deleted file mode 100644 index 002dc11..0000000 --- a/NETCore.Keycloak.Client.Tests/requirements-report.in +++ /dev/null @@ -1,2 +0,0 @@ -# The report verifier and its security regression tests need no Ansible stack. -defusedxml==0.7.1 diff --git a/NETCore.Keycloak.Client.Tests/requirements-report.txt b/NETCore.Keycloak.Client.Tests/requirements-report.txt deleted file mode 100644 index cc19ec8..0000000 --- a/NETCore.Keycloak.Client.Tests/requirements-report.txt +++ /dev/null @@ -1,6 +0,0 @@ -# This file was autogenerated by uv via the following command: -# uv pip compile NETCore.Keycloak.Client.Tests/requirements-report.in --python-version 3.12 --generate-hashes --no-emit-index-url --output-file NETCore.Keycloak.Client.Tests/requirements-report.txt -defusedxml==0.7.1 \ - --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \ - --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 - # via -r NETCore.Keycloak.Client.Tests/requirements-report.in diff --git a/NETCore.Keycloak.Client.Tests/requirements.in b/NETCore.Keycloak.Client.Tests/requirements.in deleted file mode 100644 index f77f094..0000000 --- a/NETCore.Keycloak.Client.Tests/requirements.in +++ /dev/null @@ -1,8 +0,0 @@ -# Direct dependencies for the Python 3.12 Linux integration-test environment. -# Collections are pinned separately in ansible/requirements.yml. -ansible-core==2.20.11 -cryptography==50.0.2 -defusedxml==0.7.1 -psycopg2-binary==2.9.13 -python-keycloak==7.0.2 -requests==2.34.2 diff --git a/NETCore.Keycloak.Client.Tests/requirements.txt b/NETCore.Keycloak.Client.Tests/requirements.txt deleted file mode 100644 index 3c5e256..0000000 --- a/NETCore.Keycloak.Client.Tests/requirements.txt +++ /dev/null @@ -1,732 +0,0 @@ -# This file was autogenerated by uv via the following command: -# uv pip compile NETCore.Keycloak.Client.Tests/requirements.in --python-version 3.12 --python-platform x86_64-manylinux_2_28 --generate-hashes --no-emit-index-url --output-file NETCore.Keycloak.Client.Tests/requirements.txt -aiofiles==25.1.0 \ - --hash=sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2 \ - --hash=sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695 - # via python-keycloak -ansible-core==2.20.11 \ - --hash=sha256:a136e1b04c705e44d14507058008206eac3bae9179fa8499c36b0048ef1a2b33 \ - --hash=sha256:f1738b566514c36e4cc635d10e4d728555d55867a9bdbfed709f05aa6bab4d82 - # via -r NETCore.Keycloak.Client.Tests/requirements.in -anyio==4.16.0 \ - --hash=sha256:59bb03251907983d1603d27c3087ddb1e93299ea2163adcdba37899a4fb12ffb \ - --hash=sha256:c66ae634e0b8228396b801741beb3294326d4b6102f7bba4041c0dfb58c29026 - # via httpx -certifi==2026.10.23 \ - --hash=sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd \ - --hash=sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1 - # via - # httpcore - # httpx - # requests -cffi==2.1.1 \ - --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ - --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ - --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ - --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ - --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ - --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ - --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ - --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ - --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ - --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ - --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ - --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ - --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ - --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ - --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ - --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ - --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ - --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ - --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ - --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ - --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ - --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ - --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ - --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ - --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ - --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ - --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ - --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ - --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ - --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ - --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ - --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ - --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ - --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ - --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ - --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ - --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ - --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ - --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ - --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ - --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ - --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ - --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ - --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ - --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ - --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ - --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ - --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ - --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ - --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ - --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ - --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ - --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ - --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ - --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ - --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ - --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ - --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ - --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ - --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ - --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ - --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ - --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ - --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ - --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ - --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ - --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ - --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ - --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ - --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ - --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ - --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ - --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ - --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ - --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ - --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ - --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ - --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ - --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ - --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ - --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ - --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ - --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ - --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ - --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ - --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ - --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ - --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ - --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ - --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ - --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ - --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ - --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ - --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ - --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ - --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ - --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ - --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ - --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ - --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 - # via cryptography -charset-normalizer==3.5.2 \ - --hash=sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e \ - --hash=sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf \ - --hash=sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5 \ - --hash=sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56 \ - --hash=sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26 \ - --hash=sha256:0891b9d3903c5571c03771ca669a4b0ec5618ca722a5c957d3d29cd4e5062848 \ - --hash=sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718 \ - --hash=sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93 \ - --hash=sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640 \ - --hash=sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3 \ - --hash=sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875 \ - --hash=sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e \ - --hash=sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275 \ - --hash=sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204 \ - --hash=sha256:15bb4005af6320d259dc7593ca84a38d7fe06a421dbcf7b910ae23979101e787 \ - --hash=sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234 \ - --hash=sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3 \ - --hash=sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98 \ - --hash=sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3 \ - --hash=sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187 \ - --hash=sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d \ - --hash=sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f \ - --hash=sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7 \ - --hash=sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011 \ - --hash=sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f \ - --hash=sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869 \ - --hash=sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1 \ - --hash=sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d \ - --hash=sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847 \ - --hash=sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320 \ - --hash=sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9 \ - --hash=sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93 \ - --hash=sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd \ - --hash=sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00 \ - --hash=sha256:2cc961b171b3f3440f410489ab3573e86aea8736134ebbb40ea1338b7f0831bc \ - --hash=sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0 \ - --hash=sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09 \ - --hash=sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac \ - --hash=sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621 \ - --hash=sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c \ - --hash=sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8 \ - --hash=sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a \ - --hash=sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51 \ - --hash=sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0 \ - --hash=sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef \ - --hash=sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa \ - --hash=sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6 \ - --hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649 \ - --hash=sha256:3ddacd27458c45bdacd6bd6db644bfb730efbf9e830310186e3045c9c5be8fb2 \ - --hash=sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229 \ - --hash=sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e \ - --hash=sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd \ - --hash=sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115 \ - --hash=sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9 \ - --hash=sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c \ - --hash=sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c \ - --hash=sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab \ - --hash=sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253 \ - --hash=sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995 \ - --hash=sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438 \ - --hash=sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0 \ - --hash=sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be \ - --hash=sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b \ - --hash=sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7 \ - --hash=sha256:55ea99acb17b9325618de155a0cd6a2e8f5d10be008113e1d433bbb58db543b2 \ - --hash=sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a \ - --hash=sha256:588461c2e8384d309bd63e5826019b6977bc66d629b99ac8737bb795d7b2cb5a \ - --hash=sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a \ - --hash=sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c \ - --hash=sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5 \ - --hash=sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37 \ - --hash=sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e \ - --hash=sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4 \ - --hash=sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800 \ - --hash=sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055 \ - --hash=sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e \ - --hash=sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5 \ - --hash=sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c \ - --hash=sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b \ - --hash=sha256:68eb192d85ab8e5f6ec69c2bc6ac0179fbf04a5ac1569d12fbef74883fe102d0 \ - --hash=sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80 \ - --hash=sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a \ - --hash=sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4 \ - --hash=sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2 \ - --hash=sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58 \ - --hash=sha256:75a3ceed0724d625d64b86ca20aba182e4df462e04c2414fc941c0f523f06aac \ - --hash=sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc \ - --hash=sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639 \ - --hash=sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf \ - --hash=sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d \ - --hash=sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f \ - --hash=sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c \ - --hash=sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc \ - --hash=sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4 \ - --hash=sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253 \ - --hash=sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade \ - --hash=sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858 \ - --hash=sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26 \ - --hash=sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96 \ - --hash=sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8 \ - --hash=sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249 \ - --hash=sha256:8b2bfab86aa71ae13aa41a6a26aab338e0db2b8bc75434b05aea89e011ff35a4 \ - --hash=sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13 \ - --hash=sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1 \ - --hash=sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03 \ - --hash=sha256:93223adc95033dd47133a46ccfc316a0139176fd79085762e27202ec56018f03 \ - --hash=sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e \ - --hash=sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364 \ - --hash=sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4 \ - --hash=sha256:9bde855991b7e362c146535e3136a50bfaffc0487d38b33ca7e5edefc6e23849 \ - --hash=sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0 \ - --hash=sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a \ - --hash=sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036 \ - --hash=sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3 \ - --hash=sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21 \ - --hash=sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3 \ - --hash=sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e \ - --hash=sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413 \ - --hash=sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21 \ - --hash=sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346 \ - --hash=sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429 \ - --hash=sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685 \ - --hash=sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45 \ - --hash=sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f \ - --hash=sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c \ - --hash=sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d \ - --hash=sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad \ - --hash=sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400 \ - --hash=sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb \ - --hash=sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c \ - --hash=sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc \ - --hash=sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c \ - --hash=sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74 \ - --hash=sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf \ - --hash=sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604 \ - --hash=sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f \ - --hash=sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105 \ - --hash=sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a \ - --hash=sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d \ - --hash=sha256:d4a7319f304a774bed22115bc891618e45f85065ab44ea6acd07d274e750519a \ - --hash=sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1 \ - --hash=sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5 \ - --hash=sha256:d913de495d90407cd859d263bee2e5d1a4ed3eb6573c04e70d9ec619a7cbed7f \ - --hash=sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e \ - --hash=sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709 \ - --hash=sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874 \ - --hash=sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5 \ - --hash=sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc \ - --hash=sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95 \ - --hash=sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd \ - --hash=sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0 \ - --hash=sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d \ - --hash=sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3 \ - --hash=sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c \ - --hash=sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3 \ - --hash=sha256:e80e6c2f55656b4824d72065abb4ddd6a525c74bd78a0aab5d9fc2cf4fb5af50 \ - --hash=sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491 \ - --hash=sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5 \ - --hash=sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5 \ - --hash=sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655 \ - --hash=sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288 \ - --hash=sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd \ - --hash=sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084 \ - --hash=sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d \ - --hash=sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4 \ - --hash=sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915 \ - --hash=sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1 \ - --hash=sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd \ - --hash=sha256:fc14a032f813bf5fe624d991960ea83e9715adc27e4c1830a2361eb1d02ac341 \ - --hash=sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424 \ - --hash=sha256:fd1fbe0f116b6e55da77aca2c6ddcddcfac2186cbf78bdebf40fc156efca389d \ - --hash=sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f - # via requests -cryptography==50.0.2 \ - --hash=sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602 \ - --hash=sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2 \ - --hash=sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047 \ - --hash=sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c \ - --hash=sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42 \ - --hash=sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18 \ - --hash=sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51 \ - --hash=sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81 \ - --hash=sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856 \ - --hash=sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2 \ - --hash=sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de \ - --hash=sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7 \ - --hash=sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd \ - --hash=sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2 \ - --hash=sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be \ - --hash=sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45 \ - --hash=sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0 \ - --hash=sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e \ - --hash=sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c \ - --hash=sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5 \ - --hash=sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452 \ - --hash=sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48 \ - --hash=sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05 \ - --hash=sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1 \ - --hash=sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93 \ - --hash=sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04 \ - --hash=sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e \ - --hash=sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67 \ - --hash=sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7 \ - --hash=sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107 \ - --hash=sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079 \ - --hash=sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134 \ - --hash=sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227 \ - --hash=sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1 \ - --hash=sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539 \ - --hash=sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e \ - --hash=sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d \ - --hash=sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c \ - --hash=sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd \ - --hash=sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020 \ - --hash=sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd \ - --hash=sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94 \ - --hash=sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a \ - --hash=sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408 \ - --hash=sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37 \ - --hash=sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e \ - --hash=sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454 \ - --hash=sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c \ - --hash=sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc \ - --hash=sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37 \ - --hash=sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767 \ - --hash=sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a \ - --hash=sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5 \ - --hash=sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc \ - --hash=sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67 \ - --hash=sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8 \ - --hash=sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480 \ - --hash=sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb \ - --hash=sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b - # via - # -r NETCore.Keycloak.Client.Tests/requirements.in - # ansible-core - # jwcrypto -defusedxml==0.7.1 \ - --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \ - --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61 - # via -r NETCore.Keycloak.Client.Tests/requirements.in -deprecation==2.1.0 \ - --hash=sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff \ - --hash=sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a - # via python-keycloak -h11==0.16.0 \ - --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ - --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 - # via httpcore -httpcore==1.0.9 \ - --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ - --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 - # via httpx -httpx==0.28.1 \ - --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ - --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad - # via python-keycloak -idna==3.20 \ - --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ - --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c - # via - # anyio - # httpx - # requests -jinja2==3.1.6 \ - --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ - --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 - # via ansible-core -jwcrypto==1.6.1 \ - --hash=sha256:77e856818e514cb3d64a1043b6885a7d52f5cc37773f5e944de02103c8d3a54f \ - --hash=sha256:a1e1570da5c2e35dbcd375ec1d2891a24de8eb35ee2c1fe8e91978efb72cfe90 - # via python-keycloak -markupsafe==3.0.4 \ - --hash=sha256:007e1ffd9bf65bb6ee96df7b258fc632a4868dd5566037986c64781f35a36e98 \ - --hash=sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002 \ - --hash=sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b \ - --hash=sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653 \ - --hash=sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c \ - --hash=sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e \ - --hash=sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc \ - --hash=sha256:0764a13d34cae40db7bbf3a09b7e9b491bf4603e20b263a7a9d6b8e324975d0a \ - --hash=sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92 \ - --hash=sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f \ - --hash=sha256:0cee7cb0f9a1b6892ea482237d9403b3d1b4603aee057d0ff01f0fac2d019a97 \ - --hash=sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4 \ - --hash=sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7 \ - --hash=sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691 \ - --hash=sha256:14bd2d845d62ab678eaf81da89d7b621b51756c72346745c1a594c09d49207a2 \ - --hash=sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc \ - --hash=sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde \ - --hash=sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99 \ - --hash=sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9 \ - --hash=sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df \ - --hash=sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5 \ - --hash=sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17 \ - --hash=sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8 \ - --hash=sha256:2a6ef68ae94aed8721934072b27a3b654ea2100b97e4ab864cf1489c90926fbc \ - --hash=sha256:2b2b1e18af909b448bb3cf9e3433366f7a8726271fc214e8b10e0f62a78c724b \ - --hash=sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea \ - --hash=sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248 \ - --hash=sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741 \ - --hash=sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5 \ - --hash=sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6 \ - --hash=sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7 \ - --hash=sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1 \ - --hash=sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67 \ - --hash=sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f \ - --hash=sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9 \ - --hash=sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c \ - --hash=sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc \ - --hash=sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba \ - --hash=sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17 \ - --hash=sha256:3d23795802fc8bd72534836d64489bbf0f67c088959091bdb22e10735a5107bf \ - --hash=sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6 \ - --hash=sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2 \ - --hash=sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163 \ - --hash=sha256:4a540e2d3192792fc84eced57bef37851ccb2b41f73291bb17408eea77bcd278 \ - --hash=sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d \ - --hash=sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b \ - --hash=sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634 \ - --hash=sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38 \ - --hash=sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed \ - --hash=sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c \ - --hash=sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148 \ - --hash=sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a \ - --hash=sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7 \ - --hash=sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f \ - --hash=sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811 \ - --hash=sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e \ - --hash=sha256:57f9947a7e57a081c1e3e0a2dd0d2dcf290a4531450e6f611e30084c222a7295 \ - --hash=sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2 \ - --hash=sha256:5c22873ad1f0532ba40fa1727f3c0fc1bbbaab6d373d4cbe3f0dc74b2e2521c7 \ - --hash=sha256:5e8b3d0b18fd623afa12ecb2ce8d8becef69f9b5440c6330c7972200e0bb84b0 \ - --hash=sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6 \ - --hash=sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed \ - --hash=sha256:6669c1bf34080161ce49c589cc512ef24d4c704ac9d2b2d3667f519c60418378 \ - --hash=sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0 \ - --hash=sha256:6768d67d1bce64270e0fdc2e69309d68b9b18ae56ddf6c711d168e9d051c2cac \ - --hash=sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b \ - --hash=sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96 \ - --hash=sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59 \ - --hash=sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808 \ - --hash=sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2 \ - --hash=sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb \ - --hash=sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65 \ - --hash=sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72 \ - --hash=sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8 \ - --hash=sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e \ - --hash=sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91 \ - --hash=sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a \ - --hash=sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2 \ - --hash=sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e \ - --hash=sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707 \ - --hash=sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21 \ - --hash=sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef \ - --hash=sha256:8698d70a8081ee8c090dbb394768b5789a1da8b131b5499f89d071dd3cfaf6be \ - --hash=sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453 \ - --hash=sha256:88d59b473bfb03259722600839af9bbd7fa13a2eb514beefeedb95997882f69a \ - --hash=sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6 \ - --hash=sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977 \ - --hash=sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978 \ - --hash=sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581 \ - --hash=sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692 \ - --hash=sha256:9240187afb63d2f9ddc3e032c670356fe941f6e20662ea168a5dc3f1f317e1b3 \ - --hash=sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369 \ - --hash=sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a \ - --hash=sha256:9388003072b95f2f1e3fd908604194d653ba21330d811961a78b7da1a77e9e36 \ - --hash=sha256:9438a2648b2195980cb2dd8e53ed7b8df91319e2d0b70ae61a9e1d1bc8d3bec9 \ - --hash=sha256:94e4c421742086aeee4c32a506eec8859d7634aad943f7e6aacf70f813478768 \ - --hash=sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916 \ - --hash=sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b \ - --hash=sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f \ - --hash=sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346 \ - --hash=sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c \ - --hash=sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464 \ - --hash=sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9 \ - --hash=sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee \ - --hash=sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300 \ - --hash=sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6 \ - --hash=sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d \ - --hash=sha256:ac0c7c9f1609b0c4c114feb1d7a3409564c7fb77e360bed9e97e5d25dfeaf868 \ - --hash=sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46 \ - --hash=sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97 \ - --hash=sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733 \ - --hash=sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe \ - --hash=sha256:b61687d0828e72bf5cda24a2690188f37170bd31c9359ac97e4e66569f120a16 \ - --hash=sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429 \ - --hash=sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39 \ - --hash=sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894 \ - --hash=sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c \ - --hash=sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c \ - --hash=sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169 \ - --hash=sha256:bf053da3c97a4bc5ecfbb218cdd2983febd91c617be8367d139882aa11e490aa \ - --hash=sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77 \ - --hash=sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe \ - --hash=sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad \ - --hash=sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85 \ - --hash=sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e \ - --hash=sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34 \ - --hash=sha256:cf63c214fe879a65e69a386f915e36104fc84254ab141240f8854602d8e0be2a \ - --hash=sha256:d1aca03ede943eb80ab3d63bb082c84b7aab85ea83bd0fd0c200260945fb49d9 \ - --hash=sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c \ - --hash=sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749 \ - --hash=sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214 \ - --hash=sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932 \ - --hash=sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494 \ - --hash=sha256:dd8ea6ebee7aedbf7c749fa80521d9ccf1ba473e0d1e14805caafbaad281c889 \ - --hash=sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1 \ - --hash=sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0 \ - --hash=sha256:dff05cb7016dff1e9fd68f4122c127b65dfc59de5306cfb7ad92f956f230bee2 \ - --hash=sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786 \ - --hash=sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78 \ - --hash=sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e \ - --hash=sha256:e841068dc0be4cb6dfb5c890eb88cbdcff2f4a332393c7ec94e8e618bd32c1a8 \ - --hash=sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289 \ - --hash=sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c \ - --hash=sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe \ - --hash=sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237 \ - --hash=sha256:f291bcf42ae98eb5107edb162c3c998b4a89648fd8e99ed4cbd12705292788cd \ - --hash=sha256:f61efe1d2fe0de16158a5fe1d1cf3c14bdb6aecd54d8938fd26512c525c1f624 \ - --hash=sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19 \ - --hash=sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977 \ - --hash=sha256:fd9f8797427910198f95bced71ddfed61130d7e349213bfb8466c9c99e2c46a8 \ - --hash=sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3 - # via jinja2 -packaging==26.3 \ - --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ - --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c - # via - # ansible-core - # deprecation -psycopg2-binary==2.9.13 \ - --hash=sha256:0405dd4d97720e7ab177aa02e493f524907c4cb3c445ac173e2627948d3d0528 \ - --hash=sha256:0463c00f946517f3e69192a59e6601e023ff9de45ad0a875eda3d6b1bebeb7ce \ - --hash=sha256:07b7bd9f410650c34c3532162cc329f112368d78a3fc8668cb1ea9df61bc11bf \ - --hash=sha256:086659ab083119f7ee87a779e31b94211cf162b708fc9a6bec771f75c73ac3e6 \ - --hash=sha256:08d3b81a6a91775c937abf97d4c58fc9142e8e35fb91c387d24f81d15c98e6cf \ - --hash=sha256:0a6444ac48e2c04f691c2ddd542b38ba30c89463a2d446b3d74ec7d8fc90c964 \ - --hash=sha256:0ebcf3c4266a695df9d0ef51296155f60c86ac51cf82f0d0dd2e827255a891c5 \ - --hash=sha256:13d955f6054a705a19554364fe9888d0a6e8b0746dc7ebc08a447c7b4fd4145c \ - --hash=sha256:1752b9821f1377404d65ac43af03d59a1eccc57fb2c1eb8305f9a3fe8eb7a8ba \ - --hash=sha256:190c18b97d9ef72f2e88c451b6588af90d6bd7bf54cb94b963280dc86a2c7076 \ - --hash=sha256:1f4c7bdbafdf9dc018efbc29213b73f8308332888ba76a4cf503f560bfd21705 \ - --hash=sha256:202dedd5cadb3e5dfd4d0415ab2fc5d5b44f4208de5308938e3e74ae222b638e \ - --hash=sha256:215777c62ce81c3b487cefdb6a41969944eb982309f91349ff3ca0323d6f17ed \ - --hash=sha256:27e539b4cafd5e03dcd32921db1b12dd72fe549dd06bae6d4d2a5b5838465f24 \ - --hash=sha256:28eb30bf4a52c1117406f45771038faa96f882fdeeeb0ce43b960a1dbc6c1fd2 \ - --hash=sha256:2bf9f97a6df69a5d89d054b8cf5257a0916096c479800715fbfe7974dbcb3a26 \ - --hash=sha256:2ca263643ae37998ae04d18e431df34d0d61f12b47640dab585f14b6dbe00798 \ - --hash=sha256:31db6cba66df5231dfd91d9f69188bec3fe6c8baae384e93a0ce792067ee2d98 \ - --hash=sha256:32cd049095135d2b69e824aea9056745a4aaaa9115a9febbc65584793665d0d0 \ - --hash=sha256:33a6d3c47f9655b481b2cdc1b4bf71c235e054e55663d3066036b6ce5fbe5165 \ - --hash=sha256:376ebf7d8aee4b7386b2bac31fdc27911e7e57cd0a88f1e038b8b149398ac008 \ - --hash=sha256:38397def2d794ffde9db80f63d6820253e61b17483112652a318355f51a56f50 \ - --hash=sha256:3aea95340825f5ff236e7b40f0b5602c2c77a1e95943f71fae34909834043d29 \ - --hash=sha256:3dc3372b3731b3ef23407fe06b94f640ef87a2bda242fa386033d5589c87514a \ - --hash=sha256:3e60b06ec7f9dc3e5f1106d12706514b6d6b92c3dc438fcdf4e43e65cc660d1b \ - --hash=sha256:3f699a5225094a5c61402984e2fc1eca20e940223e76767c88189efb0c313f69 \ - --hash=sha256:41c2eb569ebd0e1b02d30d361a46932923b193fe1b5e641fb4d547c75e218955 \ - --hash=sha256:4c0214c7da18a28d108aa7108c8a3cca8035c7911ec97ef9ec0827569c9a2720 \ - --hash=sha256:4d66bfd44a46eb88cff0287929a4193fb45166b6c1f84bb1b233cc17ece0813c \ - --hash=sha256:4e55357d1943673d491bbabb171c891704fc6a22441fea539e05a5c27a79ea3c \ - --hash=sha256:4ff0f575cbb14f30445858dcfdd751e043486f5290915df78a9818bc74042eff \ - --hash=sha256:5085f7ff7b1e890f279577cedeb8c628957869a340fa34a39f7f406500b3c916 \ - --hash=sha256:541a487a9ccd72b5e38f37f27b0ce78cb7eb3e336e7b5277d45463010c03a7a8 \ - --hash=sha256:562fe2a43b30e781848dce63d9080c15414c777c96df348c4342558338cc7bf3 \ - --hash=sha256:5d89e064bb12b40cad696cf4975e6da86f8c60f14cd06cb6c1bc0a7f5d01761f \ - --hash=sha256:5f04ae99c9fbb94c3197ec88599ed7db921f6adcddfe83687a74c7ead4037c22 \ - --hash=sha256:691da68ae5dd7c3ac77514357d35ece7b1ba8b5f3e6c92735198aa6159c355c8 \ - --hash=sha256:6e696297891b56ff0115f0665de6ad774e1e301e4f60745b8d5024001ae7c2f6 \ - --hash=sha256:6ede8595767e19d30a7e8a84a7d47bfde6176d45d194fed08dbb68d1584a780b \ - --hash=sha256:70d091f5c3a6177fac50c0da20181ce0e0c053f1e43c872d5f75bd6d9429c020 \ - --hash=sha256:7e2405196a8cfe6cd3e54172a54452dcf85c241eaf2e9dde7190d7469f7f5ef7 \ - --hash=sha256:81404c37e0344ebcf10aac127d33d35137e5dbab1daf9f3deee46188fd5879c2 \ - --hash=sha256:81682c227cc1849c4a6adf7b85274229073bb4c9d6ad5697222c695dcea5a8a7 \ - --hash=sha256:8cb734989420c18ca1b71a82da880e11988f5ff3fcdaadd669161de3e98794ac \ - --hash=sha256:930e7e58b33a4f9c39e7532d7a40147925cf3372baed4229cbebe0cf3ba9ce6b \ - --hash=sha256:aa37089795bd9701576edc2eb5849ce77a439eda9dfdfa47857449332cfa5292 \ - --hash=sha256:b6ae51708201f501a171b02419d0c30878a743c369c9054eb1289f0f8d5979e2 \ - --hash=sha256:c00ebe9a2f31151aade0db233dc1446513a95e92c39ce055ee097af0ae86be1c \ - --hash=sha256:c24c98fe1a113db287dfb1958771eafca97b7db812f23b7897c2a12b6b904c22 \ - --hash=sha256:c519e406287085f43aa0d3061936edf1ba51286093532f215315c6ab8ba92c3b \ - --hash=sha256:d19aec88857d2a52f99eefcefdbbb45921fb2f777bee5186a355a23d9cf8a0b9 \ - --hash=sha256:d2fc9342aad969b9a28490a4c3eaba94b35beb2d26e9a39b31d1430378aa71b2 \ - --hash=sha256:d79530b4c1af657d5620a1d21b8e39f2996aa06821d5564d05b22d6b8cd413d0 \ - --hash=sha256:db31cf7f617a51625f1473d8a66fc35dac159af8b28e80bc014ed3ee994a9fbf \ - --hash=sha256:dddfe650e7dda464d676c27fbedb5061f1ad05e1604627f54c770d7f799d36e9 \ - --hash=sha256:dde942b46ce20f6c4464cdf551f3293207f803f4e4354454eb1f5599c3eb1fa1 \ - --hash=sha256:dff5c70ed9789ccb0d97ff4a7da51dc523a255c4ec95df188fa5d44adcae4ea8 \ - --hash=sha256:e324ecf60f952d21dd11413b8bbed0951bbd99579a06fd06f28bfc37737cd373 \ - --hash=sha256:e3861eba31f8ea8663fd876166b032fd89179e42aa63764d6feb281f13f9eb60 \ - --hash=sha256:f04ada42bcd537adbaf8b7f3140237a204e452a88d0c1831cfce69f7d2e59f4e \ - --hash=sha256:f124954a32640dfb5c000d33028f48053930d7ff226bc74cde5fb316f9c6fcb6 \ - --hash=sha256:f28b5f2fa8154d0d97e97a664136f58d1639ca008d45d6e09e69fff24826abee \ - --hash=sha256:f3088eb80f58ed933c62d87128741d31e786edc862e23266d3c286763d646de0 \ - --hash=sha256:f47f23db2d70db39cfb714b64fd5df76595b51b2ec0a669710a78f2dceb0c3f8 \ - --hash=sha256:f4cdfe41149dcc5583a3b7a2f0ad433f75bb3afd1c7a7332e63df89b05e34666 \ - --hash=sha256:f818161d2302b3b3e9c75d5a1d0a5c5679e92e45cfec6432b9d5432dde5ff1f1 \ - --hash=sha256:feb7b1856f6ca805cc0e08739858f6cdfed8ce903390126af30343c62899a389 - # via -r NETCore.Keycloak.Client.Tests/requirements.in -pycparser==3.12 \ - --hash=sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e \ - --hash=sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6 - # via cffi -python-keycloak==7.0.2 \ - --hash=sha256:0e90612f0ea57822376085426086fa636a874678b03e9676d06fb0da0bb2fe2a \ - --hash=sha256:37923f9f0c8dab67a3f3b92b5a78b639fd2461548918a8d9774bd0532fb09173 - # via -r NETCore.Keycloak.Client.Tests/requirements.in -pyyaml==6.0.3 \ - --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ - --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ - --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ - --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ - --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ - --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ - --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ - --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ - --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ - --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ - --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ - --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ - --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ - --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ - --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ - --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ - --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ - --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ - --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ - --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ - --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ - --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ - --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ - --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ - --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ - --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ - --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ - --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ - --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ - --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ - --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ - --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ - --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ - --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ - --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ - --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ - --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ - --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ - --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ - --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ - --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ - --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ - --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ - --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ - --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ - --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ - --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ - --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ - --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ - --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ - --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ - --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ - --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ - --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ - --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ - --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ - --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ - --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ - --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ - --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ - --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ - --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ - --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ - --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ - --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ - --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ - --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ - --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ - --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ - --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ - --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ - --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ - --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 - # via ansible-core -requests==2.34.2 \ - --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ - --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed - # via - # -r NETCore.Keycloak.Client.Tests/requirements.in - # python-keycloak - # requests-toolbelt -requests-toolbelt==1.0.0 \ - --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \ - --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06 - # via python-keycloak -resolvelib==1.2.1 \ - --hash=sha256:7d08a2022f6e16ce405d60b68c390f054efcfd0477d4b9bd019cc941c28fad1c \ - --hash=sha256:fb06b66c8da04172d9e72a21d7d06186d8919e32ae5ab5cdf5b9d920be805ac2 - # via ansible-core -typing-extensions==4.16.0 \ - --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ - --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 - # via - # anyio - # jwcrypto -urllib3==2.8.0 \ - --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \ - --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63 - # via requests diff --git a/NETCore.Keycloak.Client.Tests/test_verify_test_results.py b/NETCore.Keycloak.Client.Tests/test_verify_test_results.py deleted file mode 100644 index 25ec5fc..0000000 --- a/NETCore.Keycloak.Client.Tests/test_verify_test_results.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Security regressions for the TRX/Cobertura acceptance gate.""" - -import sys -import tempfile -import unittest -from contextlib import redirect_stdout -from io import StringIO -from pathlib import Path -from unittest.mock import patch - -from defusedxml.common import DTDForbidden - -import verify_test_results - -PASSED_TRX = """ - - -""" -ENTITY_XML = """]> -&payload;""" - - -class ReportVerifierTests(unittest.TestCase): - """Exercise the CI entry point with valid and hostile report files.""" - - def run_verifier(self, files, *arguments): - with tempfile.TemporaryDirectory() as directory: - for name, contents in files.items(): - Path(directory, name).write_text(contents, encoding="utf-8") - output = StringIO() - with patch.object(sys, "argv", ["verify_test_results.py", directory, *arguments]): - with redirect_stdout(output): - verify_test_results.main() - return output.getvalue() - - def test_accepts_complete_passed_run(self): - output = self.run_verifier({"results.trx": PASSED_TRX}) - self.assertIn("Verified 1 passed tests", output) - - def test_rejects_missing_reports(self): - with self.assertRaisesRegex(SystemExit, "No TRX reports found"): - self.run_verifier({}) - - def test_rejects_skipped_test(self): - report = PASSED_TRX.replace('outcome="Passed"', 'outcome="NotExecuted"') - with self.assertRaisesRegex(SystemExit, "Failed, skipped, or aborted tests"): - self.run_verifier({"results.trx": report}) - - def test_rejects_trx_doctype_and_entities(self): - with self.assertRaises(DTDForbidden): - self.run_verifier({"results.trx": ENTITY_XML}) - - def test_rejects_coverage_doctype_and_entities(self): - with self.assertRaises(DTDForbidden): - self.run_verifier( - {"results.trx": PASSED_TRX, "coverage.cobertura.xml": ENTITY_XML}, - "--require-http-method-coverage", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/NETCore.Keycloak.Client.Tests/verify_test_results.py b/NETCore.Keycloak.Client.Tests/verify_test_results.py deleted file mode 100644 index 9c18c0b..0000000 --- a/NETCore.Keycloak.Client.Tests/verify_test_results.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Reject incomplete integration runs, including skipped or undiscovered tests.""" - -import argparse -import re -from pathlib import Path - -from defusedxml import ElementTree as ET - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("directory", type=Path) - parser.add_argument("--require-http-method-coverage", action="store_true") - args = parser.parse_args() - reports = list(args.directory.rglob("*.trx")) - if not reports: - raise SystemExit("No TRX reports found.") - total = 0 - for report in reports: - root = ET.parse(report, forbid_dtd=True).getroot() - counters = root.find("{*}ResultSummary/{*}Counters") - results = root.findall("{*}Results/{*}UnitTestResult") - if counters is None: - raise SystemExit(f"Missing counters: {report}") - count = int(counters.get("total", "0")) - passed = int(counters.get("passed", "0")) - if count <= 0 or count != passed or count != len(results): - raise SystemExit(f"Incomplete test run: {report} (total={count}, passed={passed})") - if any(result.get("outcome") != "Passed" for result in results): - raise SystemExit(f"Failed, skipped, or aborted tests: {report}") - total += count - print(f"Verified {total} passed tests; no failures or skips.") - if args.require_http_method_coverage: - verify_http_method_coverage(args.directory) - - -def verify_http_method_coverage(directory): - """Require execution of declared client methods, including compiler-generated async state machines.""" - interfaces = Path(__file__).resolve().parent.parent / "NETCore.Keycloak.Client/HttpClients/Abstraction" - expected = set() - for interface in interfaces.glob("IKc*.cs"): - implementation = "NETCore.Keycloak.Client.HttpClients.Implementation." + interface.stem[1:] - names = re.findall(r"(?m)^\s*Task<[^;]*?>\s+(\w+Async)\s*\(", interface.read_text(encoding="utf-8-sig")) - expected.update((implementation, name) for name in names) - if not expected: - raise SystemExit("No declared client methods found for coverage verification.") - modules = {implementation for implementation, _ in expected} - covered = set() - for report in directory.rglob("coverage.cobertura.xml"): - root = ET.parse(report, forbid_dtd=True).getroot() - for implementation in root.findall(".//class"): - name = implementation.get("name", "") - module = name.split("/", 1)[0] - if module not in modules: - continue - # Do not mistake an async lambda for its enclosing public method. - state_machine = re.fullmatch(re.escape(module) + r"/<(\w+Async)>d__\d+", name) - for method in implementation.findall("methods/method"): - method_name = method.get("name", "") - if state_machine and method_name == "MoveNext": - identity = (module, state_machine.group(1)) - elif name == module: - identity = (module, method_name) - else: - continue - if identity in expected and any(int(line.get("hits", "0")) > 0 for line in method.findall("lines/line")): - covered.add(identity) - missing = sorted(f"{module}.{name}" for module, name in expected - covered) - if missing: - raise SystemExit("Unexecuted HTTP methods:\n" + "\n".join(missing)) - print(f"Verified execution of {len(expected)} declared client methods across {len(modules)} modules.") - - -if __name__ == "__main__": - main() diff --git a/README.md b/README.md index 6634900..c6fd478 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,6 @@ Keycloak 26.8.1 为当前固定测试版本;验证范围为已有客户端 API - [📖 Basic Usage](#-basic-usage) - [📚 Documentation](#-documentation) - [🧪 Testing](#-testing) - - [📋 Test Documentation](#-test-documentation) - - [🔬 Key Testing Features](#-key-testing-features) - [⚡ Running Tests](#-running-tests) - [🤝 Contributing](#-contributing) - [📄 License](#-license) @@ -191,51 +189,32 @@ Explore our comprehensive documentation for each module: ## 🧪 Testing -Our library includes an extensive test suite ensuring reliability across multiple Keycloak versions (20.x through 26.x). The testing infrastructure leverages Docker and Ansible for automated setup and execution. +The .NET 10/MSTest suite validates the existing client API against Keycloak 26.8.1, with a full +FGAP v1 run and separate FGAP v2 security regressions. Native .NET 10 file-based applications +prepare isolated Docker Compose services and verify TRX/Cobertura reports. Python, Ansible +and Make are no longer required. -### 📋 Test Documentation +See the [Test Suite Guide](NETCore.Keycloak.Client.Tests/README.md) for configuration, +coverage, all 178 declared client methods, dependency audits and the FGAP compatibility matrix. -- 📘 [Test Suite Guide](NETCore.Keycloak.Client.Tests/README.md) - - Test patterns - - Setup instructions - - Mock data structure - -- 🔧 [Ansible Setup Guide](NETCore.Keycloak.Client.Tests/ansible/README.md) - - Environment setup - - Configuration management - - Container orchestration - -### 🔬 Key Testing Features - -1. **Version Coverage**: - - Supports Keycloak 20.x through 26.x - - Automated environment setup per version - - Parallel version testing +### ⚡ Running Tests -2. **Test Categories**: - - Authentication flows - - Authorization mechanisms - - Client operations - - Group management - - User operations +Run from the repository root with the .NET 10 SDK and Docker Compose v2: -3. **Infrastructure**: - - Docker-based environments - - Ansible automation - - Continuous Integration ready - - Comprehensive mock data +```bash +dotnet run --file tools/KeycloakTestEnvironment.cs -- up +dotnet test NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj --configuration Release \ + --collect 'XPlat Code Coverage' --logger trx --results-directory NETCore.Keycloak.Client.Tests/obj/verification +dotnet run --file tools/VerifyTestResults.cs -- NETCore.Keycloak.Client.Tests/obj/verification --require-http-method-coverage +dotnet run --file tools/KeycloakTestEnvironment.cs -- down +``` -### ⚡ Running Tests +For the 54 independent tests without Docker or integration credentials: ```bash -# Install test environment dependencies -cd NETCore.Keycloak.Client.Tests -make install_virtual_env - -# Run tests for all supported versions -dotnet cake e2e_test.cake +dotnet test NETCore.Keycloak.Client.Tests/NETCore.Keycloak.Client.Tests.csproj --filter 'TestCategory=CoverageUnit' +dotnet run --file tools/VerifyTestResults.cs -- --self-test ``` - ## 👥 Contributors Thanks to all the people who contribute to this project! diff --git a/tools/.editorconfig b/tools/.editorconfig new file mode 100644 index 0000000..158b396 --- /dev/null +++ b/tools/.editorconfig @@ -0,0 +1,3 @@ +[*.cs] +# These command-line file-based applications do not have a synchronization context. +dotnet_diagnostic.CA2007.severity = none diff --git a/tools/KeycloakTestEnvironment.cs b/tools/KeycloakTestEnvironment.cs new file mode 100644 index 0000000..df11e1b --- /dev/null +++ b/tools/KeycloakTestEnvironment.cs @@ -0,0 +1,330 @@ +#:property TargetFramework=net10.0 +#:property PublishAot=false +#:property EnforceCodeStyleInBuild=true +#:property GenerateDocumentationFile=true + +using System.Diagnostics; +using System.IO.Compression; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +// Run from any directory: dotnet run --file tools/KeycloakTestEnvironment.cs -- up|down [options] +return await EnvironmentTool.RunAsync(args); + +internal static class EnvironmentTool +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + private static readonly string Repository = + Path.GetFullPath(Path.Combine(Path.GetDirectoryName(SourcePath())!, "..")); + private const string Version = "26.8.1"; + + private static string SourcePath([CallerFilePath] string path = "") => path; + + internal static async Task RunAsync(string[] args) + { + try + { + if ( args.Length == 0 || args[0] is "--help" or "-h" ) + { + Console.WriteLine("up|down [--project netcore-keycloak-tests] [--fgap v1|v2] " + + "[--port 8080] [--invalid-port 8899] [--config path]"); + return 0; + } + + if ( args[0] is not ("up" or "down") ) throw new ArgumentException("Expected up or down."); + var options = new Dictionary(StringComparer.Ordinal); + var allowed = new[] { "--project", "--fgap", "--port", "--invalid-port", "--config" }; + for ( var i = 1; i < args.Length; i += 2 ) + { + if ( i + 1 >= args.Length || !allowed.Contains(args[i]) || !options.TryAdd(args[i], args[i + 1]) ) + throw new ArgumentException("Invalid or duplicate option. Use --help."); + } + + var project = options.GetValueOrDefault("--project", "netcore-keycloak-tests"); + if ( !Regex.IsMatch(project, "^netcore-keycloak-[a-z0-9][a-z0-9-]{0,40}$") ) + throw new ArgumentException("Project must start with netcore-keycloak- " + + "and contain lowercase letters, digits or hyphens."); + var state = Path.Combine(Repository, "NETCore.Keycloak.Client.Tests", "obj", "native-environment", project); + var compose = Path.Combine(state, "compose.json"); + var marker = Path.Combine(state, "owner.json"); + + if ( args[0] == "down" ) + { + if ( !File.Exists(marker) ) + { + Console.WriteLine("No managed environment found; nothing to stop."); + return 0; + } + + var owner = JsonNode.Parse(await File.ReadAllTextAsync(marker))!; + if ( ( string? ) owner["project"] != project || ( string? ) owner["repository"] != Repository ) + throw new InvalidOperationException("Environment ownership does not match this checkout."); + await DockerAsync("compose", "--project-name", project, "--file", compose, + "down", "--volumes", "--remove-orphans"); + // Delete only the generated files for this exact managed project. + foreach ( var file in new[] { compose, marker, Path.Combine(state, "policies.jar") } ) + File.Delete(file); + Console.WriteLine($"Stopped managed environment {project}."); + return 0; + } + + if ( File.Exists(marker) ) + throw new InvalidOperationException("This environment already exists. " + + "Run down for the same project before recreating it."); + var fgap = options.GetValueOrDefault("--fgap", + Environment.GetEnvironmentVariable("KC_FGAP_VERSION") ?? "v1"); + if ( fgap is not ("v1" or "v2") ) throw new ArgumentException("FGAP must be v1 or v2."); + var port = Port(options.GetValueOrDefault("--port", "8080")); + var invalidPort = Port(options.GetValueOrDefault("--invalid-port", "8899")); + if ( port == invalidPort ) throw new ArgumentException("Service ports must differ."); + var defaultConfig = Path.Combine(Repository, "NETCore.Keycloak.Client.Tests", + "Assets", "testing_environment.json"); + var config = Path.GetFullPath(options.GetValueOrDefault("--config", defaultConfig)); + _ = Directory.CreateDirectory(state); + RestrictDirectory(state); + var fixtures = Path.Combine(Repository, "tools", "test-environment"); + var jar = Path.Combine(state, "policies.jar"); + File.Delete(jar); + ZipFile.CreateFromDirectory(Path.Combine(fixtures, "js-policies"), jar); + // Docker's non-root Keycloak user must be able to read the provider JAR. + var password = Secret(); + var adminPassword = Secret(); + var userPassword = Secret(); + var clientSecret = Secret(); + var baseUrl = $"http://localhost:{port}"; + var services = new + { + db = new + { + image = "postgres:16", + environment = new + { + POSTGRES_DB = "keycloak", + POSTGRES_USER = "keycloak", + POSTGRES_PASSWORD = password + }, + volumes = new[] { "database:/var/lib/postgresql/data" }, + healthcheck = new + { + test = new[] { "CMD-SHELL", "pg_isready -U keycloak -d keycloak" }, + interval = "2s", + timeout = "5s", + retries = 60 + } + }, + keycloak = new + { + image = $"quay.io/keycloak/keycloak:{Version}", + command = new[] { "start-dev" }, + environment = new + { + KC_DB = "postgres", + KC_DB_USERNAME = "keycloak", + KC_DB_PASSWORD = password, + KC_DB_URL = "jdbc:postgresql://db:5432/keycloak", + KC_HEALTH_ENABLED = "true", + KC_METRICS_ENABLED = "true", + KC_BOOTSTRAP_ADMIN_USERNAME = "admin", + KC_BOOTSTRAP_ADMIN_PASSWORD = adminPassword, + KC_FEATURES = $"scripts,client-policies,admin-fine-grained-authz:{fgap},organization" + }, + ports = new[] { $"127.0.0.1:{port}:8080" }, + volumes = new[] { $"{DockerPath(jar)}:/opt/keycloak/providers/policies.jar:ro" }, + depends_on = new { db = new { condition = "service_healthy" } } + }, + invalid_response = new + { + image = "nginx:stable-alpine", + ports = new[] { $"127.0.0.1:{invalidPort}:80" }, + volumes = new[] + { + $"{DockerPath(Path.Combine(fixtures, "nginx.default.conf"))}" + + ":/etc/nginx/conf.d/default.conf:ro" + } + } + }; + await WritePrivateAsync(compose, new { services, volumes = new { database = new { } } }); + await WritePrivateAsync(marker, new { project, repository = Repository }); + await DockerAsync("compose", "--project-name", project, "--file", compose, + "up", "--detach", "--wait", "--wait-timeout", "180"); + using var http = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(30) }; + await WaitAsync(http); + using ( var response = await http.PostAsync("/realms/master/protocol/openid-connect/token", + new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "password", + ["client_id"] = "admin-cli", + ["username"] = "admin", + ["password"] = adminPassword + })) ) + { + if ( !response.IsSuccessStatusCode ) + throw new InvalidOperationException( + $"Bootstrap authentication failed: {( int ) response.StatusCode}."); + var token = await response.Content.ReadFromJsonAsync(); + http.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", ( string ) token!["access_token"]!); + } + + var fixture = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(fixtures, "realm-fixture.json")))!; + var realm = ( string ) fixture["realm"]!["realm"]!; + var api = $"/admin/realms/{Uri.EscapeDataString(realm)}"; + _ = await RequestAsync(http, HttpMethod.Post, "/admin/realms", fixture["realm"]); + _ = await RequestAsync(http, HttpMethod.Put, api + "/users/profile", fixture["userProfile"]); + foreach ( var role in fixture["realmRoles"]!.AsArray() ) + _ = await RequestAsync(http, HttpMethod.Post, api + "/roles", + new JsonObject { ["name"] = ( string ) role! }); + foreach ( var scope in fixture["clientScopes"]!.AsArray() ) + _ = await RequestAsync(http, HttpMethod.Post, api + "/client-scopes", scope); + foreach ( var client in fixture["clients"]!.AsArray() ) + { + if ( ( string? ) client!["clientId"] == "auth_client" ) client["secret"] = clientSecret; + _ = await RequestAsync(http, HttpMethod.Post, api + "/clients", client); + } + + var clients = (await RequestAsync(http, HttpMethod.Get, api + "/clients"))!.AsArray(); + var authId = ( string ) clients.Single(c => ( string? ) c!["clientId"] == "auth_client")!["id"]!; + var authz = api + $"/clients/{authId}/authz/resource-server"; + var defaults = new[] + { + ("permission", "Default Permission"), ("policy", "Default Policy"), ("resource", "Default Resource") + }; + foreach ( var (endpoint, name) in defaults ) + { + var items = (await RequestAsync(http, HttpMethod.Get, authz + "/" + endpoint))!.AsArray(); + foreach ( var item in items.Where(item => ( string? ) item!["name"] == name) ) + _ = await RequestAsync(http, HttpMethod.Delete, + authz + "/" + endpoint + "/" + ( string? ) (item!["id"] ?? item["_id"])); + } + + var username = realm + "_admin"; + _ = await RequestAsync(http, HttpMethod.Post, api + "/users", new JsonObject + { + ["username"] = username, + ["email"] = "admin@example.com", + ["firstName"] = "John", + ["lastName"] = "Doe", + ["enabled"] = true, + ["credentials"] = new JsonArray(new JsonObject + { + ["type"] = "password", + ["value"] = userPassword, + ["temporary"] = false + }) + }); + var users = (await RequestAsync(http, HttpMethod.Get, + api + "/users?exact=true&username=" + Uri.EscapeDataString(username)))!.AsArray(); + var userId = ( string ) users.Single()!["id"]!; + var managementId = ( string ) clients.Single(c => ( string? ) c!["clientId"] == "realm-management")!["id"]!; + var managementRoles = (await RequestAsync(http, HttpMethod.Get, + api + $"/clients/{managementId}/roles"))!.AsArray(); + var adminRoles = fixture["adminRoles"]!.AsArray() + .Select(r => ( string ) r!).ToHashSet(StringComparer.Ordinal); + var assignments = new JsonArray(managementRoles + .Where(r => adminRoles.Contains(( string ) r!["name"]!)).Select(r => r!.DeepClone()).ToArray()); + if ( assignments.Count != adminRoles.Count ) + throw new InvalidOperationException("Missing realm-management roles."); + _ = await RequestAsync(http, HttpMethod.Post, + api + $"/users/{userId}/role-mappings/clients/{managementId}", assignments); + await WritePrivateAsync(config, new + { + kc_version = Version, + baseUrl, + invalidBaseUrl = $"http://localhost:{invalidPort}", + auth_realm = "master", + client_id = "admin-cli", + admin = new { username = "admin", password = adminPassword }, + testing_realm = new + { + name = realm, + public_client = new { client_id = "app_client" }, + private_client = new { client_id = "auth_client", client_secret = clientSecret }, + user = new + { + username, + password = userPassword, + permissions = new[] { new { name = "auth_client", scopes = new[] { "Profile#Get" } } } + } + } + }); + Console.WriteLine($"Prepared Keycloak {Version}, FGAP {fgap}, project {project}. Configuration: {config}"); + return 0; + } + catch ( Exception exception ) + { + // Do not log HTTP response bodies or generated secrets. + Console.Error.WriteLine(exception.Message); + Console.Error.WriteLine("If provisioning failed after startup, " + + "run down with the same --project before retrying."); + return 1; + } + } + + private static int Port(string value) => int.TryParse(value, out var port) && port is >= 1024 and <= 65535 + ? port : throw new ArgumentException("Ports must be between 1024 and 65535."); + + private static string Secret() => Convert.ToHexString(RandomNumberGenerator.GetBytes(24)) + "@aA1"; + private static string DockerPath(string path) => path.Replace('\\', '/'); + + private static void RestrictDirectory(string path) + { + if ( !OperatingSystem.IsWindows() ) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + private static async Task WritePrivateAsync(string path, object value) + { + _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var options = new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write }; + if ( !OperatingSystem.IsWindows() ) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + await using var stream = new FileStream(path, options); + await JsonSerializer.SerializeAsync(stream, value, JsonOptions); + if ( !OperatingSystem.IsWindows() ) File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + + private static async Task RequestAsync( + HttpClient http, HttpMethod method, string path, JsonNode? body = null) + { + using var request = new HttpRequestMessage(method, path); + if ( body is not null ) request.Content = JsonContent.Create(body); + using var response = await http.SendAsync(request); + if ( !response.IsSuccessStatusCode ) + throw new InvalidOperationException( + $"Fixture request {method} {path} failed: {( int ) response.StatusCode}."); + var content = await response.Content.ReadAsStringAsync(); + return string.IsNullOrWhiteSpace(content) ? null : JsonNode.Parse(content); + } + + private static async Task WaitAsync(HttpClient http) + { + var deadline = DateTime.UtcNow.AddMinutes(4); + while ( DateTime.UtcNow < deadline ) + { + try + { + using var response = await http.GetAsync("/realms/master/.well-known/openid-configuration"); + if ( response.IsSuccessStatusCode ) return; + } + catch ( HttpRequestException ) { } + catch ( TaskCanceledException ) { } + await Task.Delay(TimeSpan.FromSeconds(2)); + } + + throw new TimeoutException("Keycloak did not become ready within four minutes."); + } + + private static async Task DockerAsync(params string[] arguments) + { + var start = new ProcessStartInfo("docker") { UseShellExecute = false }; + foreach ( var argument in arguments ) start.ArgumentList.Add(argument); + using var process = Process.Start(start) ?? throw new InvalidOperationException("Cannot start Docker."); + await process.WaitForExitAsync(); + if ( process.ExitCode != 0 ) + throw new InvalidOperationException($"Docker Compose failed with exit code {process.ExitCode}."); + } +} diff --git a/tools/VerifyTestResults.cs b/tools/VerifyTestResults.cs new file mode 100644 index 0000000..dec4087 --- /dev/null +++ b/tools/VerifyTestResults.cs @@ -0,0 +1,265 @@ +#:property TargetFramework=net10.0 +#:property PublishAot=false +#:property EnforceCodeStyleInBuild=true +#:property GenerateDocumentationFile=true + +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Xml; +using System.Xml.Linq; + +try +{ + if ( args is ["--self-test"] ) + { + ReportVerifier.SelfTest(); + } + else if ( args is ["--audit-json", var audit] ) + { + ReportVerifier.VerifyAudit(audit); + } + else if ( args.Length is 1 or 2 && (args.Length == 1 || args[1] == "--require-http-method-coverage") ) + { + ReportVerifier.Verify(args[0], args.Length == 2); + } + else + { + throw new ArgumentException("Usage: [--require-http-method-coverage] " + + "| --self-test | --audit-json "); + } + + return 0; +} +catch ( Exception exception ) +{ + Console.Error.WriteLine(exception.Message); + return 1; +} + +internal static class ReportVerifier +{ + private static string SourcePath([CallerFilePath] string path = "") => path; + + internal static void Verify(string directory, bool requireCoverage) + { + var reports = Directory.GetFiles(directory, "*.trx", SearchOption.AllDirectories); + if ( reports.Length == 0 ) throw new InvalidDataException("No TRX reports found."); + var total = reports.Sum(report => VerifyRun(ReadXml(report))); + Console.WriteLine($"Verified {total} passed tests; no failures or skips."); + if ( !requireCoverage ) return; + var interfaces = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(SourcePath())!, + "..", "NETCore.Keycloak.Client", "HttpClients", "Abstraction")); + var expected = new HashSet<(string Module, string Method)>(); + foreach ( var path in Directory.GetFiles(interfaces, "IKc*.cs") ) + { + var module = "NETCore.Keycloak.Client.HttpClients.Implementation." + + Path.GetFileNameWithoutExtension(path)[1..]; + foreach ( Match match in Regex.Matches(File.ReadAllText(path), @"(?m)^\s*Task<[^;]*?>\s+(\w+Async)\s*\(") ) + _ = expected.Add((module, match.Groups[1].Value)); + } + + if ( expected.Count == 0 ) + throw new InvalidDataException("No declared client methods found for coverage verification."); + var coverage = Directory.GetFiles(directory, "coverage.cobertura.xml", SearchOption.AllDirectories); + VerifyCoverage(coverage.Select(ReadXml), expected); + var modules = expected.Select(m => m.Module).Distinct().Count(); + Console.WriteLine($"Verified execution of {expected.Count} declared client methods across {modules} modules."); + } + + private static XDocument ReadXml(string path) + { + using var reader = XmlReader.Create(path, XmlSettings()); + return XDocument.Load(reader); + } + + private static XmlReaderSettings XmlSettings() => new() + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + MaxCharactersInDocument = 128L * 1024 * 1024 + }; + + private static int Number(XElement element, string attribute) => + int.Parse(( string? ) element.Attribute(attribute) ?? "0", CultureInfo.InvariantCulture); + + private static int VerifyRun(XDocument document) + { + var root = document.Root ?? throw new InvalidDataException("Missing report root."); + var ns = root.Name.Namespace; + var counters = root.Element(ns + "ResultSummary")?.Element(ns + "Counters") ?? + throw new InvalidDataException("Missing counters."); + var results = root.Element(ns + "Results")?.Elements(ns + "UnitTestResult").ToArray() ?? []; + var count = Number(counters, "total"); + if ( count <= 0 || count != Number(counters, "passed") || count != results.Length ) + throw new InvalidDataException("Incomplete test run: totals, passed tests and result records must match."); + if ( results.Any(result => ( string? ) result.Attribute("outcome") != "Passed") ) + throw new InvalidDataException("Failed, skipped, or aborted tests."); + var incomplete = new[] + { + "failed", "error", "timeout", "aborted", "inconclusive", "notRunnable", "notExecuted", "disconnected" + }; + foreach ( var name in incomplete ) + if ( Number(counters, name) != 0 ) throw new InvalidDataException($"Nonzero {name} test counter."); + return count; + } + + private static void VerifyCoverage( + IEnumerable documents, HashSet<(string Module, string Method)> expected) + { + var covered = new HashSet<(string Module, string Method)>(); + foreach ( var document in documents ) + { + foreach ( var implementation in document.Descendants("class") ) + { + var name = ( string? ) implementation.Attribute("name") ?? ""; + var module = name.Split('/')[0]; + var stateMachine = Regex.Match(name, "^" + Regex.Escape(module) + @"/<(\w+Async)>d__\d+$"); + foreach ( var method in implementation.Element("methods")?.Elements("method") ?? [] ) + { + var methodName = ( string? ) method.Attribute("name") ?? ""; + (string Module, string Method) identity; + if ( stateMachine.Success && methodName == "MoveNext" ) + identity = (module, stateMachine.Groups[1].Value); + else if ( name == module ) identity = (module, methodName); + else continue; // An async lambda must not satisfy the enclosing public method. + if ( expected.Contains(identity) && + method.Element("lines")?.Elements("line").Any(line => Number(line, "hits") > 0) == true ) + _ = covered.Add(identity); + } + } + } + + var missing = expected.Except(covered).OrderBy(m => m.Module, StringComparer.Ordinal) + .ThenBy(m => m.Method, StringComparer.Ordinal).ToArray(); + if ( missing.Length > 0 ) + throw new InvalidDataException("Unexecuted HTTP methods:\n" + + string.Join('\n', missing.Select(m => m.Module + "." + m.Method))); + } + + internal static void VerifyAudit(string path) + { + var document = JsonNode.Parse(File.ReadAllText(path)) ?? + throw new InvalidDataException("Missing NuGet audit document."); + var parameters = (( string? ) document["parameters"] ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries); + if ( ( int? ) document["version"] != 1 || !parameters.Contains("--vulnerable") || + !parameters.Contains("--include-transitive") + || document["sources"] is not JsonArray sources || sources.Count == 0 ) + throw new InvalidDataException("Expected a version 1 NuGet vulnerability audit " + + "including transitive packages and sources."); + if ( document["projects"] is not JsonArray projects || projects.Count == 0 ) + throw new InvalidDataException("No projects in NuGet audit."); + if ( document["problems"] is JsonArray problems && problems.Count > 0 ) + throw new InvalidDataException("NuGet audit reported problems."); + foreach ( var project in projects ) + { + if ( string.IsNullOrWhiteSpace(( string? ) project?["path"]) ) + throw new InvalidDataException("Missing project path in NuGet audit."); + // --vulnerable omits frameworks entirely when a project has no findings. + if ( project?["frameworks"] is not JsonArray targets ) continue; + foreach ( var framework in targets ) + { + foreach ( var group in new[] { "topLevelPackages", "transitivePackages" } ) + { + if ( framework?[group] is not JsonArray packages ) continue; + foreach ( var package in packages ) + if ( package?["vulnerabilities"] is JsonArray vulnerabilities && vulnerabilities.Count > 0 ) + throw new InvalidDataException($"Vulnerable NuGet dependency: {package["id"]}."); + } + } + } + + Console.WriteLine($"Verified NuGet audit: {projects.Count} projects, no reported vulnerabilities."); + } + + internal static void SelfTest() + { + const string passed = """ + + + """; + var tests = 0; + void Accept(Action action) { action(); tests++; } + void Reject(Action action) where T : Exception + { + try { action(); } + catch ( T ) { tests++; return; } + throw new InvalidOperationException("A negative verifier regression unexpectedly passed."); + } + + XDocument Parse(string xml) + { + using var input = new StringReader(xml); + using var reader = XmlReader.Create(input, XmlSettings()); + return XDocument.Load(reader); + } + + Accept(() => VerifyRun(Parse(passed))); + Accept(() => VerifyRun(Parse(passed.Replace("", "", + StringComparison.Ordinal)))); + Reject(() => VerifyRun(Parse(""))); + Reject(() => VerifyRun(Parse(passed.Replace("total=\"1\"", "total=\"0\"", + StringComparison.Ordinal)))); + Reject(() => VerifyRun(Parse(passed.Replace("passed=\"1\"", "passed=\"0\"", + StringComparison.Ordinal)))); + Reject(() => VerifyRun(Parse(passed.Replace( + "outcome=\"Passed\"", "outcome=\"NotExecuted\"", + StringComparison.Ordinal)))); + Reject(() => VerifyRun(Parse(passed.Replace("(() => Parse("]>&payload;")); + Reject(() => Parse("")); + var expected = new HashSet<(string Module, string Method)> { ("Client", "GetAsync") }; + XDocument Coverage(string name, string method, int hits) => Parse($""" + + + """); + Accept(() => VerifyCoverage([Coverage("Client", "GetAsync", 1)], expected)); + Accept(() => VerifyCoverage([Coverage("Client/<GetAsync>d__7", "MoveNext", 1)], expected)); + Reject(() => VerifyCoverage([Coverage("Client", "GetAsync", 0)], expected)); + Reject(() => VerifyCoverage( + [Coverage("Client/<>c__DisplayClass/<GetAsync>b__0", "MoveNext", 1)], expected)); + Reject(() => VerifyCoverage([], expected)); + var temp = Path.Combine(Path.GetTempPath(), "keycloak-report-check-" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(temp); + try + { + Reject(() => Verify(temp, false)); + File.WriteAllText(Path.Combine(temp, "passed.trx"), passed); + File.WriteAllText(Path.Combine(temp, "coverage.cobertura.xml"), ""); + Reject(() => Verify(temp, true)); + var audit = Path.Combine(temp, "audit.json"); + const string auditHeader = "\"version\":1,\"parameters\":\"--vulnerable --include-transitive\"," + + "\"sources\":[\"https://api.nuget.org/v3/index.json\"],"; + File.WriteAllText(audit, "{" + auditHeader + "\"projects\":[{\"path\":\"sample.csproj\"}]}"); + Accept(() => VerifyAudit(audit)); + File.WriteAllText(audit, "{" + auditHeader + """ + "projects":[{"path":"sample.csproj","frameworks":[{ + "topLevelPackages":[{"id":"unsafe","vulnerabilities":[{}]}]}]}]} + """); + Reject(() => VerifyAudit(audit)); + File.WriteAllText(audit, "{" + auditHeader + "\"projects\":[]}"); + Reject(() => VerifyAudit(audit)); + File.WriteAllText(audit, "{" + auditHeader + """ + "projects":[{"path":"sample.csproj","frameworks":[{ + "transitivePackages":[{"id":"unsafe","vulnerabilities":[{}]}]}]}]} + """); + Reject(() => VerifyAudit(audit)); + File.WriteAllText(audit, "{" + auditHeader + """ + "projects":[{"path":"sample.csproj"}],"problems":[{"message":"network failure"}]} + """); + Reject(() => VerifyAudit(audit)); + File.WriteAllText(audit, "{\"projects\":[{\"path\":\"sample.csproj\"}]}"); + Reject(() => VerifyAudit(audit)); + } + finally + { + foreach ( var file in Directory.GetFiles(temp) ) File.Delete(file); + Directory.Delete(temp); + } + + Console.WriteLine($"Passed {tests} native verifier regressions " + + "(TRX, XML security, async coverage and NuGet audit)."); + } +} diff --git a/NETCore.Keycloak.Client.Tests/ansible/files/js-policies/META-INF/keycloak-scripts.json b/tools/test-environment/js-policies/META-INF/keycloak-scripts.json similarity index 100% rename from NETCore.Keycloak.Client.Tests/ansible/files/js-policies/META-INF/keycloak-scripts.json rename to tools/test-environment/js-policies/META-INF/keycloak-scripts.json diff --git a/NETCore.Keycloak.Client.Tests/ansible/files/js-policies/can-delete-roles.js b/tools/test-environment/js-policies/can-delete-roles.js similarity index 100% rename from NETCore.Keycloak.Client.Tests/ansible/files/js-policies/can-delete-roles.js rename to tools/test-environment/js-policies/can-delete-roles.js diff --git a/NETCore.Keycloak.Client.Tests/ansible/files/js-policies/can-view-roles.js b/tools/test-environment/js-policies/can-view-roles.js similarity index 100% rename from NETCore.Keycloak.Client.Tests/ansible/files/js-policies/can-view-roles.js rename to tools/test-environment/js-policies/can-view-roles.js diff --git a/NETCore.Keycloak.Client.Tests/ansible/files/js-policies/can-write-roles.js b/tools/test-environment/js-policies/can-write-roles.js similarity index 100% rename from NETCore.Keycloak.Client.Tests/ansible/files/js-policies/can-write-roles.js rename to tools/test-environment/js-policies/can-write-roles.js diff --git a/NETCore.Keycloak.Client.Tests/ansible/files/js-policies/should-be-account-owner.js b/tools/test-environment/js-policies/should-be-account-owner.js similarity index 100% rename from NETCore.Keycloak.Client.Tests/ansible/files/js-policies/should-be-account-owner.js rename to tools/test-environment/js-policies/should-be-account-owner.js diff --git a/NETCore.Keycloak.Client.Tests/ansible/files/js-policies/should-be-business-account-owner.js b/tools/test-environment/js-policies/should-be-business-account-owner.js similarity index 100% rename from NETCore.Keycloak.Client.Tests/ansible/files/js-policies/should-be-business-account-owner.js rename to tools/test-environment/js-policies/should-be-business-account-owner.js diff --git a/NETCore.Keycloak.Client.Tests/ansible/files/nginx.default.conf b/tools/test-environment/nginx.default.conf similarity index 100% rename from NETCore.Keycloak.Client.Tests/ansible/files/nginx.default.conf rename to tools/test-environment/nginx.default.conf diff --git a/tools/test-environment/realm-fixture.json b/tools/test-environment/realm-fixture.json new file mode 100644 index 0000000..6ac0f0c --- /dev/null +++ b/tools/test-environment/realm-fixture.json @@ -0,0 +1,712 @@ +{ + "realm": { + "id": "net_core_keycloak_tests", + "realm": "net_core_keycloak_tests", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "clientOfflineSessionMaxLifespan": "0", + "clientSessionIdleTimeout": "0", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "accessCodeLifespan": 60, + "accessCodeLifespanLogin": 1800, + "accessTokenLifespanForImplicitFlow": 300, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "browserFlow": "browser", + "clientAuthenticationFlow": "clients", + "defaultSignatureAlgorithm": "PS512", + "directGrantFlow": "direct grant", + "dockerAuthenticationFlow": "docker auth", + "duplicateEmailsAllowed": false, + "editUsernameAllowed": true, + "enabled": true, + "loginWithEmailAllowed": true, + "offlineSessionIdleTimeout": 1800, + "offlineSessionMaxLifespan": 1800, + "offlineSessionMaxLifespanEnabled": true, + "passwordPolicy": "hashIterations(99) and length(10) and notUsername(undefined) and notEmail(undefined) and specialChars(1) and upperCase(1) and lowerCase(1) and digits(1) and passwordHistory(12)", + "refreshTokenMaxReuse": 0, + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "registrationFlow": "registration", + "rememberMe": false, + "resetCredentialsFlow": "reset credentials", + "resetPasswordAllowed": false, + "revokeRefreshToken": false, + "sslRequired": "external", + "ssoSessionIdleTimeout": 43200, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespan": 36000, + "ssoSessionMaxLifespanRememberMe": 0, + "userManagedAccessAllowed": true, + "verifyEmail": false, + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyDigits": 6, + "otpPolicyInitialCounter": 0, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyType": "totp", + "bruteForceProtected": false, + "maxFailureWaitSeconds": 900, + "waitIncrementSeconds": 60, + "failureFactor": 30, + "permanentLockout": true, + "quickLoginCheckMilliSeconds": 1000, + "minimumQuickLoginWaitSeconds": 60, + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "eventsEnabled": true, + "eventsExpiration": 3600, + "adminEventsEnabled": true, + "adminEventsDetailsEnabled": true, + "enabledEventTypes": [ + "LOGIN", + "LOGOUT" + ], + "eventsListeners": [ + "jboss-logging" + ], + "organizationsEnabled": true + }, + "realmRoles": [ + "kc_client_role_1", + "kc_client_role_2", + "kc_client_role_3" + ], + "adminRoles": [ + "query-realms", + "manage-authorization", + "view-users", + "manage-clients", + "view-authorization", + "query-groups", + "view-realm", + "manage-users", + "query-clients", + "realm-admin", + "view-clients", + "query-users" + ], + "userProfile": { + "attributes": [ + { + "name": "username", + "displayName": "${username}", + "validations": { + "length": { + "min": 3, + "max": 255 + } + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": false + }, + { + "name": "email", + "displayName": "${email}", + "validations": { + "email": {}, + "length": { + "max": 255 + } + }, + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": false + }, + { + "name": "firstName", + "displayName": "${firstName}", + "validations": { + "length": { + "max": 255 + } + }, + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": false + }, + { + "name": "lastName", + "displayName": "${lastName}", + "validations": { + "length": { + "max": 255 + } + }, + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": false + }, + { + "name": "account_owner", + "displayName": "Account owner", + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "edit": [ + "admin" + ], + "view": [ + "admin" + ] + }, + "multivalued": false + }, + { + "name": "business_account_owner", + "displayName": "Business account owner", + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "edit": [ + "admin" + ], + "view": [ + "admin" + ] + }, + "multivalued": false + } + ], + "groups": [ + { + "name": "user-metadata", + "displayHeader": "User metadata", + "displayDescription": "Attributes, which refer to user metadata" + } + ] + }, + "clientScopes": [ + { + "name": "attributes", + "description": "user attributes", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "protocol": "openid-connect", + "name": "account_owner", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "config": { + "aggregate.attrs": "false", + "userinfo.token.claim": "false", + "multivalued": "false", + "user.attribute": "account_owner", + "id.token.claim": "false", + "access.token.claim": "true", + "claim.name": "account_owner" + } + }, + { + "protocol": "openid-connect", + "name": "business_account_owner", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "config": { + "aggregate.attrs": "false", + "userinfo.token.claim": "false", + "multivalued": "false", + "user.attribute": "business_account_owner", + "id.token.claim": "false", + "access.token.claim": "true", + "claim.name": "business_account_owner" + } + } + ] + }, + { + "name": "audiences", + "description": "oidc audiences", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "name": "app_client audience mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.client.audience": "app_client", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] + } + ], + "clients": [ + { + "publicClient": true, + "clientAuthenticatorType": "client-secret", + "clientId": "app_client", + "consentRequired": false, + "description": "Application client", + "directAccessGrantsEnabled": true, + "enabled": true, + "frontchannelLogout": true, + "fullScopeAllowed": true, + "implicitFlowEnabled": false, + "name": "Application client", + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false, + "alwaysDisplayInConsole": false, + "standardFlowEnabled": false, + "defaultClientScopes": [ + "acr", + "attributes", + "audiences", + "email", + "profile", + "roles", + "web-origins", + "basic" + ], + "optionalClientScopes": [ + "offline_access", + "address", + "microprofile-jwt", + "phone" + ], + "attributes": { + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "display.on.consent.screen": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false" + } + }, + { + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "clientId": "auth_client", + "consentRequired": false, + "description": "Authentication service", + "directAccessGrantsEnabled": true, + "enabled": true, + "frontchannelLogout": true, + "fullScopeAllowed": true, + "implicitFlowEnabled": false, + "name": "Authentication service", + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true, + "alwaysDisplayInConsole": false, + "standardFlowEnabled": false, + "authorizationSettings": { + "allowRemoteResourceManagement": true, + "policyEnforcementMode": "ENFORCING", + "resources": [ + { + "name": "Account", + "type": "urn:api:resources:account", + "ownerManagedAccess": true, + "displayName": "Account endpoint", + "uris": [ + "/api/*/auth/account/*" + ], + "scopes": [ + { + "name": "UpdateEmail" + }, + { + "name": "UpdatePassword" + }, + { + "name": "Verify" + }, + { + "name": "GetVerificationCode" + } + ] + }, + { + "name": "Profile", + "type": "urn:api:resources:profile", + "ownerManagedAccess": true, + "displayName": "Profile endpoint", + "uris": [ + "/api/*/auth/profile/*" + ], + "scopes": [ + { + "name": "Get" + }, + { + "name": "Business" + } + ] + }, + { + "name": "Role", + "type": "urn:api:resources:role", + "ownerManagedAccess": true, + "displayName": "Role endpoint", + "uris": [ + "/api/*/auth/role/*" + ], + "scopes": [ + { + "name": "Create" + }, + { + "name": "Update" + }, + { + "name": "List" + }, + { + "name": "AvailablePermissions" + }, + { + "name": "Assign" + }, + { + "name": "Unassign" + }, + { + "name": "ChangeOwner" + }, + { + "name": "Delete" + } + ] + }, + { + "name": "TwoFactor", + "type": "urn:api:resources:twofactor", + "ownerManagedAccess": true, + "displayName": "Two factor endpoint", + "uris": [ + "/api/*/auth/twofactor/*" + ], + "scopes": [ + { + "name": "Enable" + }, + { + "name": "Verify" + }, + { + "name": "Disable" + }, + { + "name": "GetRecoveryCodes" + } + ] + } + ], + "policies": [ + { + "name": "should_be_client_user", + "description": "Should be a client user", + "type": "role", + "logic": "POSITIVE", + "config": { + "roles": "[{\"id\":\"kc_client_role_1\",\"required\":false},{\"id\":\"kc_client_role_2\",\"required\":false},{\"id\":\"kc_client_role_3\",\"required\":false}]" + } + }, + { + "name": "should_be_account_owner", + "description": "Should be self account owner", + "type": "script-should-be-account-owner.js", + "logic": "POSITIVE" + }, + { + "name": "should_be_business_account_owner", + "description": "Should be business account owner", + "type": "script-should-be-business-account-owner.js", + "logic": "POSITIVE" + }, + { + "name": "can_view_roles", + "description": "Can view roles", + "type": "script-can-view-roles.js", + "logic": "POSITIVE" + }, + { + "name": "can_write_roles", + "description": "Can write roles", + "type": "script-can-write-roles.js", + "logic": "POSITIVE" + }, + { + "name": "can_delete_roles", + "description": "Can delete roles", + "type": "script-can-delete-roles.js", + "logic": "POSITIVE" + }, + { + "name": "should_be_client_owner", + "description": "Should have client role and business account owner", + "type": "aggregate", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "applyPolicies": "[\"should_be_client_user\",\"should_be_business_account_owner\"]" + } + }, + { + "name": "should_be_client_role_writer", + "description": "Should have client role and can write client roles", + "type": "aggregate", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "applyPolicies": "[\"should_be_client_user\",\"can_write_roles\"]" + } + }, + { + "name": "should_be_client_role_viewer", + "description": "Should have client role and can view client roles", + "type": "aggregate", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "applyPolicies": "[\"should_be_client_user\",\"can_view_roles\"]" + } + }, + { + "name": "should_be_client_role_remover", + "description": "Should have client role and can delete client roles", + "type": "aggregate", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "applyPolicies": "[\"should_be_client_user\",\"can_delete_roles\"]" + } + }, + { + "name": "permit_users_to_manage_their_accounts", + "description": "Permit users to manager their accounts.", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "scopes": "[\"UpdatePassword\",\"Verify\",\"GetVerificationCode\",\"UpdateEmail\",\"Verify\",\"Enable\",\"Disable\",\"GetRecoveryCodes\"]", + "resources": "[\"Account\",\"TwoFactor\"]", + "applyPolicies": "[\"should_be_client_user\",\"should_be_account_owner\"]" + } + }, + { + "name": "permit_users_to_get_their_profiles", + "description": "Permit users to fetch their profiles.", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "scopes": "[\"Get\",\"Business\"]", + "resources": "[\"Profile\"]", + "applyPolicies": "[\"should_be_client_user\",\"should_be_account_owner\"]" + } + }, + { + "name": "permit_users_to_view_roles", + "description": "Permit users to list roles and permissions.", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "scopes": "[\"List\",\"AvailablePermissions\"]", + "resources": "[\"Role\"]", + "applyPolicies": "[\"should_be_client_owner\",\"should_be_client_role_viewer\",\"should_be_client_role_writer\",\"should_be_client_role_remover\"]" + } + }, + { + "name": "permit_users_to_write_roles", + "description": "Permit users to create, update, assign and un-assign roles.", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "scopes": "[\"Create\",\"Update\",\"Assign\",\"Unassign\"]", + "resources": "[\"Role\"]", + "applyPolicies": "[\"should_be_client_owner\",\"should_be_client_role_writer\"]" + } + }, + { + "name": "permit_users_to_delete_roles", + "description": "Permit users to delete roles.", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "scopes": "[\"Delete\"]", + "resources": "[\"Role\"]", + "applyPolicies": "[\"should_be_client_owner\",\"should_be_client_role_writer\"]" + } + }, + { + "name": "permit_users_to_change_owner", + "description": "Permit users to change account owner.", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "scopes": "[\"ChangeOwner\"]", + "resources": "[\"Role\"]", + "applyPolicies": "[\"should_be_client_owner\"]" + } + } + ], + "scopes": [ + { + "name": "GetVerificationCode", + "displayName": "Get verification code scope." + }, + { + "name": "UpdateEmail", + "displayName": "Update email scope" + }, + { + "name": "UpdatePassword", + "displayName": "Update password scope" + }, + { + "name": "Verify", + "displayName": "Verify scope" + }, + { + "name": "Business", + "displayName": "Business scope" + }, + { + "name": "Create", + "displayName": "Create scope" + }, + { + "name": "Update", + "displayName": "Update scope" + }, + { + "name": "List", + "displayName": "List scope" + }, + { + "name": "AvailablePermissions", + "displayName": "Get available permissions scope" + }, + { + "name": "Assign", + "displayName": "Assign scope" + }, + { + "name": "Unassign", + "displayName": "Unassign scope" + }, + { + "name": "ChangeOwner", + "displayName": "Change owner scope" + }, + { + "name": "Delete", + "displayName": "Delete scope" + }, + { + "name": "Enable", + "displayName": "Enable scope" + }, + { + "name": "Disable", + "displayName": "Disable scope" + }, + { + "name": "GetRecoveryCodes", + "displayName": "Get multi factor recovery codes scope" + } + ], + "decisionStrategy": "UNANIMOUS" + }, + "attributes": { + "client.secret.creation.time": "1681353515", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "display.on.consent.screen": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false" + } + } + ] +} From 7e2779d85da03ca0323263c039f32df50c5a6bb8 Mon Sep 17 00:00:00 2001 From: jeff Date: Wed, 4 Nov 2026 12:22:52 +0800 Subject: [PATCH 7/7] =?UTF-8?q?Simplify=20review=20findings=20/=20?= =?UTF-8?q?=E7=B2=BE=E7=AE=80=E6=96=B9=E6=B3=95=E5=B9=B6=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E4=B8=8D=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split native environment provisioning and NuGet audit validation into focused private helpers. Split optional user search filters out of BuildQuery while retaining query order, formatting and public behavior. Address all three CodeFactor method-complexity findings without exclusions. 拆分原生环境搭建、NuGet 审计和用户查询构造中的复杂方法, 保持公开接口、查询参数顺序、格式和既有功能不变。 通过真实回归验证,未增加复杂度或许可证忽略规则。 Validation / 验证: - Rebuilt .NET 10 Release; 434/434 full tests and 31/31 FGAP v2 tests pass. - All 178 HTTP methods executed; zero failed or skipped tests. - Coverage: 99.66% lines (3519/3531), 83.72% branches (494/590). - Native verifier: 22 regressions pass on Windows and Linux. - NuGet audit reports no vulnerabilities; workflow/static checks pass. - FOSSA scan 128535862: License scan passed, 15 direct dependencies; the previous seven flagged issues disappeared with the Python removal. Refs Black-Cockpit/NETCore.Keycloak#28. --- NETCore.Keycloak.Client.Tests/README.md | 9 +- .../Models/Users/KcUserFilter.cs | 18 +- tools/KeycloakTestEnvironment.cs | 430 ++++++++++-------- tools/VerifyTestResults.cs | 50 +- 4 files changed, 281 insertions(+), 226 deletions(-) diff --git a/NETCore.Keycloak.Client.Tests/README.md b/NETCore.Keycloak.Client.Tests/README.md index f0a6d47..199f37a 100644 --- a/NETCore.Keycloak.Client.Tests/README.md +++ b/NETCore.Keycloak.Client.Tests/README.md @@ -114,7 +114,7 @@ Local verification on 2026-11-03, .NET 10 Release and Keycloak 26.8.1: | Full v1 run | 380 passed, no skips | 434 passed, no skips | | Additional independent tests | — | 54 passed without integration credentials | | Applicable v2 regressions | 31 passed | 31 passed | -| Line coverage, Cobertura rate | 92.75% | 99.65% | +| Line coverage, Cobertura rate | 92.75% | 99.66% | | Branch coverage | 70.59% | 83.72% | | Unexecuted source lines | 255 | 12 | @@ -124,6 +124,13 @@ executed. The same native tool provisions FGAP v2 for its separate 31 applicable The report verifier has 22 native self-tests. GitHub's Ubuntu 24.04 jobs still require upstream approval and must pass before merge. +The native-tool migration removed the 26-package Python inventory. FOSSA scan 128535862 +reported `License scan passed` with 15 direct dependencies, replacing the previous seven flagged +license issues. This public scan is limited to one dependency level; the CLI profiles and CI NuGet +audit continue to include transitive dependencies. Existing user-query construction was split into +private helpers for CodeFactor review, preserving parameter order, formatting and public behavior. +The final library report covers 3519 of 3531 source lines and 494 of 590 branches. + The 12 remaining lines are internal argument/null guards, the defensive null token-response return, repeated-disposal handling and the finalizer path. No reflection into private methods, forced garbage collection or coverage exclusions were added to inflate the result. diff --git a/NETCore.Keycloak.Client/Models/Users/KcUserFilter.cs b/NETCore.Keycloak.Client/Models/Users/KcUserFilter.cs index 62b1e6a..d25ed30 100644 --- a/NETCore.Keycloak.Client/Models/Users/KcUserFilter.cs +++ b/NETCore.Keycloak.Client/Models/Users/KcUserFilter.cs @@ -121,6 +121,19 @@ public class KcUserFilter : KcFilter $"&first={string.Create(CultureInfo.CurrentCulture, $"{First}").ToLower(CultureInfo.CurrentCulture)}"); } + AppendSearchFilters(builder); + + // Add username filter and return the final query + return string.IsNullOrWhiteSpace(Username) + ? builder.ToString() + : builder.Append(CultureInfo.CurrentCulture, $"&username={Username}").ToString(); + } + + /// + /// Appends the optional name, identity provider and search filters in their existing order. + /// + private void AppendSearchFilters(StringBuilder builder) + { // Add first name filter if ( !string.IsNullOrWhiteSpace(FirstName) ) { @@ -156,10 +169,5 @@ public class KcUserFilter : KcFilter { _ = builder.Append(CultureInfo.CurrentCulture, $"&search={Search}"); } - - // Add username filter and return the final query - return string.IsNullOrWhiteSpace(Username) - ? builder.ToString() - : builder.Append(CultureInfo.CurrentCulture, $"&username={Username}").ToString(); } } diff --git a/tools/KeycloakTestEnvironment.cs b/tools/KeycloakTestEnvironment.cs index df11e1b..ac0d207 100644 --- a/tools/KeycloakTestEnvironment.cs +++ b/tools/KeycloakTestEnvironment.cs @@ -37,14 +37,7 @@ internal static async Task RunAsync(string[] args) } if ( args[0] is not ("up" or "down") ) throw new ArgumentException("Expected up or down."); - var options = new Dictionary(StringComparer.Ordinal); - var allowed = new[] { "--project", "--fgap", "--port", "--invalid-port", "--config" }; - for ( var i = 1; i < args.Length; i += 2 ) - { - if ( i + 1 >= args.Length || !allowed.Contains(args[i]) || !options.TryAdd(args[i], args[i + 1]) ) - throw new ArgumentException("Invalid or duplicate option. Use --help."); - } - + var options = ParseOptions(args); var project = options.GetValueOrDefault("--project", "netcore-keycloak-tests"); if ( !Regex.IsMatch(project, "^netcore-keycloak-[a-z0-9][a-z0-9-]{0,40}$") ) throw new ArgumentException("Project must start with netcore-keycloak- " + @@ -53,218 +46,255 @@ internal static async Task RunAsync(string[] args) var compose = Path.Combine(state, "compose.json"); var marker = Path.Combine(state, "owner.json"); - if ( args[0] == "down" ) - { - if ( !File.Exists(marker) ) - { - Console.WriteLine("No managed environment found; nothing to stop."); - return 0; - } + if ( args[0] == "down" ) await StopAsync(project, state, compose, marker); + else await StartAsync(options, project, state, compose, marker); + return 0; + } + catch ( Exception exception ) + { + // Do not log HTTP response bodies or generated secrets. + Console.Error.WriteLine(exception.Message); + Console.Error.WriteLine("If provisioning failed after startup, " + + "run down with the same --project before retrying."); + return 1; + } + } - var owner = JsonNode.Parse(await File.ReadAllTextAsync(marker))!; - if ( ( string? ) owner["project"] != project || ( string? ) owner["repository"] != Repository ) - throw new InvalidOperationException("Environment ownership does not match this checkout."); - await DockerAsync("compose", "--project-name", project, "--file", compose, - "down", "--volumes", "--remove-orphans"); - // Delete only the generated files for this exact managed project. - foreach ( var file in new[] { compose, marker, Path.Combine(state, "policies.jar") } ) - File.Delete(file); - Console.WriteLine($"Stopped managed environment {project}."); - return 0; - } + private static Dictionary ParseOptions(string[] args) + { + var options = new Dictionary(StringComparer.Ordinal); + var allowed = new[] { "--project", "--fgap", "--port", "--invalid-port", "--config" }; + for ( var i = 1; i < args.Length; i += 2 ) + { + if ( i + 1 >= args.Length || !allowed.Contains(args[i]) || !options.TryAdd(args[i], args[i + 1]) ) + throw new ArgumentException("Invalid or duplicate option. Use --help."); + } + return options; + } + + private static async Task StopAsync(string project, string state, string compose, string marker) + { + if ( !File.Exists(marker) ) + { + Console.WriteLine("No managed environment found; nothing to stop."); + return; + } - if ( File.Exists(marker) ) - throw new InvalidOperationException("This environment already exists. " + - "Run down for the same project before recreating it."); - var fgap = options.GetValueOrDefault("--fgap", - Environment.GetEnvironmentVariable("KC_FGAP_VERSION") ?? "v1"); - if ( fgap is not ("v1" or "v2") ) throw new ArgumentException("FGAP must be v1 or v2."); - var port = Port(options.GetValueOrDefault("--port", "8080")); - var invalidPort = Port(options.GetValueOrDefault("--invalid-port", "8899")); - if ( port == invalidPort ) throw new ArgumentException("Service ports must differ."); - var defaultConfig = Path.Combine(Repository, "NETCore.Keycloak.Client.Tests", - "Assets", "testing_environment.json"); - var config = Path.GetFullPath(options.GetValueOrDefault("--config", defaultConfig)); - _ = Directory.CreateDirectory(state); - RestrictDirectory(state); - var fixtures = Path.Combine(Repository, "tools", "test-environment"); - var jar = Path.Combine(state, "policies.jar"); - File.Delete(jar); - ZipFile.CreateFromDirectory(Path.Combine(fixtures, "js-policies"), jar); - // Docker's non-root Keycloak user must be able to read the provider JAR. - var password = Secret(); - var adminPassword = Secret(); - var userPassword = Secret(); - var clientSecret = Secret(); - var baseUrl = $"http://localhost:{port}"; - var services = new + var owner = JsonNode.Parse(await File.ReadAllTextAsync(marker))!; + if ( ( string? ) owner["project"] != project || ( string? ) owner["repository"] != Repository ) + throw new InvalidOperationException("Environment ownership does not match this checkout."); + await DockerAsync("compose", "--project-name", project, "--file", compose, + "down", "--volumes", "--remove-orphans"); + // Delete only the generated files for this exact managed project. + foreach ( var file in new[] { compose, marker, Path.Combine(state, "policies.jar") } ) + File.Delete(file); + Console.WriteLine($"Stopped managed environment {project}."); + } + + private static async Task StartAsync(Dictionary options, + string project, string state, string compose, string marker) + { + if ( File.Exists(marker) ) + throw new InvalidOperationException("This environment already exists. " + + "Run down for the same project before recreating it."); + var fgap = options.GetValueOrDefault("--fgap", + Environment.GetEnvironmentVariable("KC_FGAP_VERSION") ?? "v1"); + if ( fgap is not ("v1" or "v2") ) throw new ArgumentException("FGAP must be v1 or v2."); + var port = Port(options.GetValueOrDefault("--port", "8080")); + var invalidPort = Port(options.GetValueOrDefault("--invalid-port", "8899")); + if ( port == invalidPort ) throw new ArgumentException("Service ports must differ."); + var defaultConfig = Path.Combine(Repository, "NETCore.Keycloak.Client.Tests", + "Assets", "testing_environment.json"); + var config = Path.GetFullPath(options.GetValueOrDefault("--config", defaultConfig)); + _ = Directory.CreateDirectory(state); + RestrictDirectory(state); + var fixtures = Path.Combine(Repository, "tools", "test-environment"); + var jar = Path.Combine(state, "policies.jar"); + File.Delete(jar); + ZipFile.CreateFromDirectory(Path.Combine(fixtures, "js-policies"), jar); + // Docker's non-root Keycloak user must be able to read the provider JAR. + var password = Secret(); + var adminPassword = Secret(); + var userPassword = Secret(); + var clientSecret = Secret(); + var baseUrl = $"http://localhost:{port}"; + var services = new + { + db = new { - db = new + image = "postgres:16", + environment = new { - image = "postgres:16", - environment = new - { - POSTGRES_DB = "keycloak", - POSTGRES_USER = "keycloak", - POSTGRES_PASSWORD = password - }, - volumes = new[] { "database:/var/lib/postgresql/data" }, - healthcheck = new - { - test = new[] { "CMD-SHELL", "pg_isready -U keycloak -d keycloak" }, - interval = "2s", - timeout = "5s", - retries = 60 - } + POSTGRES_DB = "keycloak", + POSTGRES_USER = "keycloak", + POSTGRES_PASSWORD = password }, - keycloak = new + volumes = new[] { "database:/var/lib/postgresql/data" }, + healthcheck = new { - image = $"quay.io/keycloak/keycloak:{Version}", - command = new[] { "start-dev" }, - environment = new - { - KC_DB = "postgres", - KC_DB_USERNAME = "keycloak", - KC_DB_PASSWORD = password, - KC_DB_URL = "jdbc:postgresql://db:5432/keycloak", - KC_HEALTH_ENABLED = "true", - KC_METRICS_ENABLED = "true", - KC_BOOTSTRAP_ADMIN_USERNAME = "admin", - KC_BOOTSTRAP_ADMIN_PASSWORD = adminPassword, - KC_FEATURES = $"scripts,client-policies,admin-fine-grained-authz:{fgap},organization" - }, - ports = new[] { $"127.0.0.1:{port}:8080" }, - volumes = new[] { $"{DockerPath(jar)}:/opt/keycloak/providers/policies.jar:ro" }, - depends_on = new { db = new { condition = "service_healthy" } } - }, - invalid_response = new - { - image = "nginx:stable-alpine", - ports = new[] { $"127.0.0.1:{invalidPort}:80" }, - volumes = new[] - { - $"{DockerPath(Path.Combine(fixtures, "nginx.default.conf"))}" + - ":/etc/nginx/conf.d/default.conf:ro" - } + test = new[] { "CMD-SHELL", "pg_isready -U keycloak -d keycloak" }, + interval = "2s", + timeout = "5s", + retries = 60 } - }; - await WritePrivateAsync(compose, new { services, volumes = new { database = new { } } }); - await WritePrivateAsync(marker, new { project, repository = Repository }); - await DockerAsync("compose", "--project-name", project, "--file", compose, - "up", "--detach", "--wait", "--wait-timeout", "180"); - using var http = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(30) }; - await WaitAsync(http); - using ( var response = await http.PostAsync("/realms/master/protocol/openid-connect/token", - new FormUrlEncodedContent(new Dictionary + }, + keycloak = new + { + image = $"quay.io/keycloak/keycloak:{Version}", + command = new[] { "start-dev" }, + environment = new { - ["grant_type"] = "password", - ["client_id"] = "admin-cli", - ["username"] = "admin", - ["password"] = adminPassword - })) ) + KC_DB = "postgres", + KC_DB_USERNAME = "keycloak", + KC_DB_PASSWORD = password, + KC_DB_URL = "jdbc:postgresql://db:5432/keycloak", + KC_HEALTH_ENABLED = "true", + KC_METRICS_ENABLED = "true", + KC_BOOTSTRAP_ADMIN_USERNAME = "admin", + KC_BOOTSTRAP_ADMIN_PASSWORD = adminPassword, + KC_FEATURES = $"scripts,client-policies,admin-fine-grained-authz:{fgap},organization" + }, + ports = new[] { $"127.0.0.1:{port}:8080" }, + volumes = new[] { $"{DockerPath(jar)}:/opt/keycloak/providers/policies.jar:ro" }, + depends_on = new { db = new { condition = "service_healthy" } } + }, + invalid_response = new { - if ( !response.IsSuccessStatusCode ) - throw new InvalidOperationException( - $"Bootstrap authentication failed: {( int ) response.StatusCode}."); - var token = await response.Content.ReadFromJsonAsync(); - http.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", ( string ) token!["access_token"]!); + image = "nginx:stable-alpine", + ports = new[] { $"127.0.0.1:{invalidPort}:80" }, + volumes = new[] + { + $"{DockerPath(Path.Combine(fixtures, "nginx.default.conf"))}" + + ":/etc/nginx/conf.d/default.conf:ro" + } } - - var fixture = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(fixtures, "realm-fixture.json")))!; - var realm = ( string ) fixture["realm"]!["realm"]!; - var api = $"/admin/realms/{Uri.EscapeDataString(realm)}"; - _ = await RequestAsync(http, HttpMethod.Post, "/admin/realms", fixture["realm"]); - _ = await RequestAsync(http, HttpMethod.Put, api + "/users/profile", fixture["userProfile"]); - foreach ( var role in fixture["realmRoles"]!.AsArray() ) - _ = await RequestAsync(http, HttpMethod.Post, api + "/roles", - new JsonObject { ["name"] = ( string ) role! }); - foreach ( var scope in fixture["clientScopes"]!.AsArray() ) - _ = await RequestAsync(http, HttpMethod.Post, api + "/client-scopes", scope); - foreach ( var client in fixture["clients"]!.AsArray() ) + }; + await WritePrivateAsync(compose, new { services, volumes = new { database = new { } } }); + await WritePrivateAsync(marker, new { project, repository = Repository }); + await DockerAsync("compose", "--project-name", project, "--file", compose, + "up", "--detach", "--wait", "--wait-timeout", "180"); + var (realm, username) = await CreateFixtureAsync(baseUrl, fixtures, + adminPassword, userPassword, clientSecret); + await WritePrivateAsync(config, new + { + kc_version = Version, + baseUrl, + invalidBaseUrl = $"http://localhost:{invalidPort}", + auth_realm = "master", + client_id = "admin-cli", + admin = new { username = "admin", password = adminPassword }, + testing_realm = new { - if ( ( string? ) client!["clientId"] == "auth_client" ) client["secret"] = clientSecret; - _ = await RequestAsync(http, HttpMethod.Post, api + "/clients", client); + name = realm, + public_client = new { client_id = "app_client" }, + private_client = new { client_id = "auth_client", client_secret = clientSecret }, + user = new + { + username, + password = userPassword, + permissions = new[] { new { name = "auth_client", scopes = new[] { "Profile#Get" } } } + } } + }); + Console.WriteLine($"Prepared Keycloak {Version}, FGAP {fgap}, project {project}. Configuration: {config}"); + } - var clients = (await RequestAsync(http, HttpMethod.Get, api + "/clients"))!.AsArray(); - var authId = ( string ) clients.Single(c => ( string? ) c!["clientId"] == "auth_client")!["id"]!; - var authz = api + $"/clients/{authId}/authz/resource-server"; - var defaults = new[] - { - ("permission", "Default Permission"), ("policy", "Default Policy"), ("resource", "Default Resource") - }; - foreach ( var (endpoint, name) in defaults ) + private static async Task<(string Realm, string Username)> CreateFixtureAsync( + string baseUrl, string fixtures, string adminPassword, string userPassword, string clientSecret) + { + using var http = new HttpClient { BaseAddress = new Uri(baseUrl), Timeout = TimeSpan.FromSeconds(30) }; + await WaitAsync(http); + using ( var response = await http.PostAsync("/realms/master/protocol/openid-connect/token", + new FormUrlEncodedContent(new Dictionary { - var items = (await RequestAsync(http, HttpMethod.Get, authz + "/" + endpoint))!.AsArray(); - foreach ( var item in items.Where(item => ( string? ) item!["name"] == name) ) - _ = await RequestAsync(http, HttpMethod.Delete, - authz + "/" + endpoint + "/" + ( string? ) (item!["id"] ?? item["_id"])); - } + ["grant_type"] = "password", + ["client_id"] = "admin-cli", + ["username"] = "admin", + ["password"] = adminPassword + })) ) + { + if ( !response.IsSuccessStatusCode ) + throw new InvalidOperationException( + $"Bootstrap authentication failed: {( int ) response.StatusCode}."); + var token = await response.Content.ReadFromJsonAsync(); + http.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", ( string ) token!["access_token"]!); + } - var username = realm + "_admin"; - _ = await RequestAsync(http, HttpMethod.Post, api + "/users", new JsonObject - { - ["username"] = username, - ["email"] = "admin@example.com", - ["firstName"] = "John", - ["lastName"] = "Doe", - ["enabled"] = true, - ["credentials"] = new JsonArray(new JsonObject - { - ["type"] = "password", - ["value"] = userPassword, - ["temporary"] = false - }) - }); - var users = (await RequestAsync(http, HttpMethod.Get, - api + "/users?exact=true&username=" + Uri.EscapeDataString(username)))!.AsArray(); - var userId = ( string ) users.Single()!["id"]!; - var managementId = ( string ) clients.Single(c => ( string? ) c!["clientId"] == "realm-management")!["id"]!; - var managementRoles = (await RequestAsync(http, HttpMethod.Get, - api + $"/clients/{managementId}/roles"))!.AsArray(); - var adminRoles = fixture["adminRoles"]!.AsArray() - .Select(r => ( string ) r!).ToHashSet(StringComparer.Ordinal); - var assignments = new JsonArray(managementRoles - .Where(r => adminRoles.Contains(( string ) r!["name"]!)).Select(r => r!.DeepClone()).ToArray()); - if ( assignments.Count != adminRoles.Count ) - throw new InvalidOperationException("Missing realm-management roles."); - _ = await RequestAsync(http, HttpMethod.Post, - api + $"/users/{userId}/role-mappings/clients/{managementId}", assignments); - await WritePrivateAsync(config, new - { - kc_version = Version, - baseUrl, - invalidBaseUrl = $"http://localhost:{invalidPort}", - auth_realm = "master", - client_id = "admin-cli", - admin = new { username = "admin", password = adminPassword }, - testing_realm = new - { - name = realm, - public_client = new { client_id = "app_client" }, - private_client = new { client_id = "auth_client", client_secret = clientSecret }, - user = new - { - username, - password = userPassword, - permissions = new[] { new { name = "auth_client", scopes = new[] { "Profile#Get" } } } - } - } - }); - Console.WriteLine($"Prepared Keycloak {Version}, FGAP {fgap}, project {project}. Configuration: {config}"); - return 0; + var fixture = JsonNode.Parse(await File.ReadAllTextAsync(Path.Combine(fixtures, "realm-fixture.json")))!; + var realm = ( string ) fixture["realm"]!["realm"]!; + var api = $"/admin/realms/{Uri.EscapeDataString(realm)}"; + _ = await RequestAsync(http, HttpMethod.Post, "/admin/realms", fixture["realm"]); + _ = await RequestAsync(http, HttpMethod.Put, api + "/users/profile", fixture["userProfile"]); + foreach ( var role in fixture["realmRoles"]!.AsArray() ) + _ = await RequestAsync(http, HttpMethod.Post, api + "/roles", + new JsonObject { ["name"] = ( string ) role! }); + foreach ( var scope in fixture["clientScopes"]!.AsArray() ) + _ = await RequestAsync(http, HttpMethod.Post, api + "/client-scopes", scope); + foreach ( var client in fixture["clients"]!.AsArray() ) + { + if ( ( string? ) client!["clientId"] == "auth_client" ) client["secret"] = clientSecret; + _ = await RequestAsync(http, HttpMethod.Post, api + "/clients", client); } - catch ( Exception exception ) + + var clients = (await RequestAsync(http, HttpMethod.Get, api + "/clients"))!.AsArray(); + await RemoveDefaultAuthorizationAsync(http, api, clients); + var username = await CreateAdministratorAsync(http, api, realm, fixture, clients, userPassword); + return (realm, username); + } + + private static async Task RemoveDefaultAuthorizationAsync(HttpClient http, string api, JsonArray clients) + { + var authId = ( string ) clients.Single(c => ( string? ) c!["clientId"] == "auth_client")!["id"]!; + var authz = api + $"/clients/{authId}/authz/resource-server"; + var defaults = new[] { - // Do not log HTTP response bodies or generated secrets. - Console.Error.WriteLine(exception.Message); - Console.Error.WriteLine("If provisioning failed after startup, " + - "run down with the same --project before retrying."); - return 1; + ("permission", "Default Permission"), ("policy", "Default Policy"), ("resource", "Default Resource") + }; + foreach ( var (endpoint, name) in defaults ) + { + var items = (await RequestAsync(http, HttpMethod.Get, authz + "/" + endpoint))!.AsArray(); + foreach ( var item in items.Where(item => ( string? ) item!["name"] == name) ) + _ = await RequestAsync(http, HttpMethod.Delete, + authz + "/" + endpoint + "/" + ( string? ) (item!["id"] ?? item["_id"])); } } + private static async Task CreateAdministratorAsync(HttpClient http, string api, string realm, + JsonNode fixture, JsonArray clients, string userPassword) + { + var username = realm + "_admin"; + _ = await RequestAsync(http, HttpMethod.Post, api + "/users", new JsonObject + { + ["username"] = username, + ["email"] = "admin@example.com", + ["firstName"] = "John", + ["lastName"] = "Doe", + ["enabled"] = true, + ["credentials"] = new JsonArray(new JsonObject + { + ["type"] = "password", + ["value"] = userPassword, + ["temporary"] = false + }) + }); + var users = (await RequestAsync(http, HttpMethod.Get, + api + "/users?exact=true&username=" + Uri.EscapeDataString(username)))!.AsArray(); + var userId = ( string ) users.Single()!["id"]!; + var managementId = ( string ) clients.Single(c => ( string? ) c!["clientId"] == "realm-management")!["id"]!; + var managementRoles = (await RequestAsync(http, HttpMethod.Get, + api + $"/clients/{managementId}/roles"))!.AsArray(); + var adminRoles = fixture["adminRoles"]!.AsArray() + .Select(r => ( string ) r!).ToHashSet(StringComparer.Ordinal); + var assignments = new JsonArray(managementRoles + .Where(r => adminRoles.Contains(( string ) r!["name"]!)).Select(r => r!.DeepClone()).ToArray()); + if ( assignments.Count != adminRoles.Count ) + throw new InvalidOperationException("Missing realm-management roles."); + _ = await RequestAsync(http, HttpMethod.Post, + api + $"/users/{userId}/role-mappings/clients/{managementId}", assignments); + return username; + } + private static int Port(string value) => int.TryParse(value, out var port) && port is >= 1024 and <= 65535 ? port : throw new ArgumentException("Ports must be between 1024 and 65535."); diff --git a/tools/VerifyTestResults.cs b/tools/VerifyTestResults.cs index dec4087..4c70373 100644 --- a/tools/VerifyTestResults.cs +++ b/tools/VerifyTestResults.cs @@ -139,6 +139,17 @@ private static void VerifyCoverage( } internal static void VerifyAudit(string path) + { + var document = ReadAudit(path); + if ( document["projects"] is not JsonArray projects || projects.Count == 0 ) + throw new InvalidDataException("No projects in NuGet audit."); + if ( document["problems"] is JsonArray problems && problems.Count > 0 ) + throw new InvalidDataException("NuGet audit reported problems."); + foreach ( var project in projects ) VerifyProject(project); + Console.WriteLine($"Verified NuGet audit: {projects.Count} projects, no reported vulnerabilities."); + } + + private static JsonNode ReadAudit(string path) { var document = JsonNode.Parse(File.ReadAllText(path)) ?? throw new InvalidDataException("Missing NuGet audit document."); @@ -148,29 +159,28 @@ internal static void VerifyAudit(string path) || document["sources"] is not JsonArray sources || sources.Count == 0 ) throw new InvalidDataException("Expected a version 1 NuGet vulnerability audit " + "including transitive packages and sources."); - if ( document["projects"] is not JsonArray projects || projects.Count == 0 ) - throw new InvalidDataException("No projects in NuGet audit."); - if ( document["problems"] is JsonArray problems && problems.Count > 0 ) - throw new InvalidDataException("NuGet audit reported problems."); - foreach ( var project in projects ) + return document; + } + + private static void VerifyProject(JsonNode? project) + { + if ( string.IsNullOrWhiteSpace(( string? ) project?["path"]) ) + throw new InvalidDataException("Missing project path in NuGet audit."); + // --vulnerable omits frameworks entirely when a project has no findings. + if ( project?["frameworks"] is not JsonArray targets ) return; + foreach ( var framework in targets ) { - if ( string.IsNullOrWhiteSpace(( string? ) project?["path"]) ) - throw new InvalidDataException("Missing project path in NuGet audit."); - // --vulnerable omits frameworks entirely when a project has no findings. - if ( project?["frameworks"] is not JsonArray targets ) continue; - foreach ( var framework in targets ) - { - foreach ( var group in new[] { "topLevelPackages", "transitivePackages" } ) - { - if ( framework?[group] is not JsonArray packages ) continue; - foreach ( var package in packages ) - if ( package?["vulnerabilities"] is JsonArray vulnerabilities && vulnerabilities.Count > 0 ) - throw new InvalidDataException($"Vulnerable NuGet dependency: {package["id"]}."); - } - } + VerifyPackages(framework?["topLevelPackages"]); + VerifyPackages(framework?["transitivePackages"]); } + } - Console.WriteLine($"Verified NuGet audit: {projects.Count} projects, no reported vulnerabilities."); + private static void VerifyPackages(JsonNode? node) + { + if ( node is not JsonArray packages ) return; + foreach ( var package in packages ) + if ( package?["vulnerabilities"] is JsonArray vulnerabilities && vulnerabilities.Count > 0 ) + throw new InvalidDataException($"Vulnerable NuGet dependency: {package["id"]}."); } internal static void SelfTest()