initial version of the client v2 policy test (#46029)

* initial version of the policy v2 policy test

fixes: #46074

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* Extract client profile/policy to dedicated method

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* removed test method prefix

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

---------

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Martin Bartoš <mabartos@redhat.com>
Co-authored-by: Martin Bartoš <mabartos@redhat.com>
This commit is contained in:
Erik Jan de Wit 2026-02-06 18:16:15 +01:00 committed by GitHub
parent c7c27a031e
commit 618384efe9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -0,0 +1,542 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.tests.admin.client.v2;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import org.keycloak.admin.api.AdminApi;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator;
import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator;
import org.keycloak.authentication.authenticators.client.JWTClientSecretAuthenticator;
import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator;
import org.keycloak.common.Profile;
import org.keycloak.representations.admin.v2.OIDCClientRepresentation;
import org.keycloak.representations.idm.ClientPoliciesRepresentation;
import org.keycloak.representations.idm.ClientPolicyConditionConfigurationRepresentation;
import org.keycloak.representations.idm.ClientPolicyConditionRepresentation;
import org.keycloak.representations.idm.ClientPolicyExecutorRepresentation;
import org.keycloak.representations.idm.ClientPolicyRepresentation;
import org.keycloak.representations.idm.ClientProfileRepresentation;
import org.keycloak.representations.idm.ClientProfilesRepresentation;
import org.keycloak.services.clientpolicy.condition.ClientUpdaterContextConditionFactory;
import org.keycloak.services.clientpolicy.executor.SecureClientAuthenticatorExecutor;
import org.keycloak.services.clientpolicy.executor.SecureClientAuthenticatorExecutorFactory;
import org.keycloak.testframework.annotations.InjectAdminClient;
import org.keycloak.testframework.annotations.InjectHttpClient;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.http.HttpMessage;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpPatch;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.util.EntityUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Tests that client policies are properly executed when creating/updating clients via the Admin API v2.
*
* These tests verify that the client policy framework correctly intercepts REGISTER, UPDATE, REGISTERED,
* and UPDATED events when clients are managed through the v2 API endpoints.
*
* Note: Currently the v2 API creates clients in two phases:
* 1. Create a minimal client with just clientId and protocol
* 2. Update the client model with the full representation
*
* This means client policies are triggered on the minimal representation during CREATE,
* which doesn't include the client authenticator type. The policy is then triggered again
* on UPDATE with the full model.
*
* @author <a href="mailto:erik.dewit@gmail.com">Erik de Wit</a>
*/
@KeycloakIntegrationTest(config = ClientPoliciesV2Test.AdminV2Config.class)
public class ClientPoliciesV2Test {
public static final String HOSTNAME_LOCAL_ADMIN = "http://localhost:8080/admin/api/master/clients/v2";
private static final String PROFILE_NAME = "TestProfile";
private static final String POLICY_NAME = "TestPolicy";
private static ObjectMapper mapper;
@InjectHttpClient
CloseableHttpClient client;
@InjectAdminClient
Keycloak adminClient;
@BeforeAll
public static void setupMapper() {
mapper = new ObjectMapper();
}
@AfterEach
public void cleanup() throws Exception {
// Clean up any test clients
cleanupClient("test-policy-client");
cleanupClient("test-auto-config-client");
cleanupClient("test-put-update-client");
cleanupClient("test-patch-update-client");
// Revert to builtin profiles/policies
revertToBuiltinProfiles();
revertToBuiltinPolicies();
}
/**
* Test that creating a client via POST with an unacceptable client authenticator fails
* when a policy requires specific authenticators.
*/
@Test
public void createClientWithUnacceptableAuthType() throws Exception {
// Setup policy that only allows JWT-based authenticators
setupPolicyClientIdAndSecretNotAcceptable();
// Try to create a client with client-secret authenticator (which should be rejected)
HttpPost request = new HttpPost(HOSTNAME_LOCAL_ADMIN);
setAuthHeader(request);
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
OIDCClientRepresentation rep = new OIDCClientRepresentation();
rep.setClientId("test-policy-client");
rep.setEnabled(true);
var auth = new OIDCClientRepresentation.Auth();
auth.setMethod(ClientIdAndSecretAuthenticator.PROVIDER_ID);
auth.setSecret("secret");
rep.setAuth(auth);
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(request)) {
// Should fail with 400 Bad Request due to policy violation
assertEquals(400, response.getStatusLine().getStatusCode());
String body = EntityUtils.toString(response.getEntity());
assertThat(body, containsString("invalid_client_metadata"));
}
}
/**
* Test that creating a client via POST with an acceptable client authenticator succeeds
* when a policy requires specific authenticators AND has a default authenticator configured.
*
* Note: Due to how the v2 API creates clients (minimal representation first, then update),
* the policy must have a default authenticator configured for creation to succeed.
*/
@Test
public void createClientWithAcceptableAuthType() throws Exception {
// Setup policy that allows JWT-based authenticators AND sets a default
setupPolicyWithAutoConfiguration();
// Create a confidential client with an auth method - policy should allow it
HttpPost request = new HttpPost(HOSTNAME_LOCAL_ADMIN);
setAuthHeader(request);
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
OIDCClientRepresentation rep = new OIDCClientRepresentation();
rep.setClientId("test-policy-client");
rep.setEnabled(true);
// Set auth method to one of the allowed types
var auth = new OIDCClientRepresentation.Auth();
auth.setMethod(JWTClientSecretAuthenticator.PROVIDER_ID);
auth.setSecret("secret");
rep.setAuth(auth);
// Add a login flow to ensure it's treated as a confidential client
rep.setLoginFlows(Set.of(OIDCClientRepresentation.Flow.STANDARD));
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(request)) {
int statusCode = response.getStatusLine().getStatusCode();
String body = EntityUtils.toString(response.getEntity());
assertEquals(201, statusCode, "Expected 201 but got " + statusCode + ": " + body);
OIDCClientRepresentation created = mapper.readValue(body, OIDCClientRepresentation.class);
assertEquals("test-policy-client", created.getClientId());
// Auth should be present for confidential clients
assertThat(created.getAuth(), is(notNullValue()));
assertThat(created.getAuth().getMethod(), is(JWTClientSecretAuthenticator.PROVIDER_ID));
}
}
/**
* Test that a client created without auth is treated as a public client.
* When no auth is specified in the v2 representation, the client is created as public,
* and public clients don't have auth information returned.
*/
@Test
public void publicClientWithoutAuth() throws Exception {
// Create a client without specifying auth - should be created as public client
HttpPost request = new HttpPost(HOSTNAME_LOCAL_ADMIN);
setAuthHeader(request);
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
OIDCClientRepresentation rep = new OIDCClientRepresentation();
rep.setClientId("test-auto-config-client");
rep.setEnabled(true);
rep.setLoginFlows(Set.of(OIDCClientRepresentation.Flow.STANDARD));
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(request)) {
int statusCode = response.getStatusLine().getStatusCode();
String body = EntityUtils.toString(response.getEntity());
assertEquals(201, statusCode, "Expected 201 but got " + statusCode + ": " + body);
OIDCClientRepresentation created = mapper.readValue(body, OIDCClientRepresentation.class);
assertEquals("test-auto-config-client", created.getClientId());
// Public clients don't have auth configuration
// The auth field is null for public clients in the v2 API
}
}
/**
* Test that updating a client via PUT with an unacceptable client authenticator fails.
*/
@Test
public void updateClientViaPutWithUnacceptableAuthType() throws Exception {
// First create a client with acceptable auth type before policy is set
HttpPut createRequest = new HttpPut(HOSTNAME_LOCAL_ADMIN + "/test-put-update-client");
setAuthHeader(createRequest);
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
OIDCClientRepresentation rep = new OIDCClientRepresentation();
rep.setClientId("test-put-update-client");
rep.setEnabled(true);
var auth = new OIDCClientRepresentation.Auth();
auth.setMethod(JWTClientSecretAuthenticator.PROVIDER_ID);
auth.setSecret("secret");
rep.setAuth(auth);
createRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(createRequest)) {
assertEquals(201, response.getStatusLine().getStatusCode());
EntityUtils.consumeQuietly(response.getEntity());
}
// Now setup policy
setupPolicyClientIdAndSecretNotAcceptable();
// Try to update the client to use an unacceptable auth type
HttpPut updateRequest = new HttpPut(HOSTNAME_LOCAL_ADMIN + "/test-put-update-client");
setAuthHeader(updateRequest);
updateRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
rep.setAuth(new OIDCClientRepresentation.Auth());
rep.getAuth().setMethod(ClientIdAndSecretAuthenticator.PROVIDER_ID);
rep.getAuth().setSecret("newsecret");
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
// Should fail with 400 Bad Request due to policy violation
assertEquals(400, response.getStatusLine().getStatusCode());
String body = EntityUtils.toString(response.getEntity());
assertThat(body, containsString("invalid_client_metadata"));
}
}
/**
* Test that updating a client via PUT with an acceptable client authenticator succeeds.
* Creates the client before setting up the policy to avoid the initial creation issue.
*/
@Test
public void updateClientViaPutWithAcceptableAuthType() throws Exception {
// First create a client BEFORE the policy is set
HttpPut createRequest = new HttpPut(HOSTNAME_LOCAL_ADMIN + "/test-put-update-client");
setAuthHeader(createRequest);
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
OIDCClientRepresentation rep = new OIDCClientRepresentation();
rep.setClientId("test-put-update-client");
rep.setEnabled(true);
var auth = new OIDCClientRepresentation.Auth();
auth.setMethod(JWTClientSecretAuthenticator.PROVIDER_ID);
auth.setSecret("secret");
rep.setAuth(auth);
createRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(createRequest)) {
assertEquals(201, response.getStatusLine().getStatusCode());
EntityUtils.consumeQuietly(response.getEntity());
}
// Now setup policy
setupPolicyClientIdAndSecretNotAcceptable();
// Update the client to use another acceptable auth type
HttpPut updateRequest = new HttpPut(HOSTNAME_LOCAL_ADMIN + "/test-put-update-client");
setAuthHeader(updateRequest);
updateRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
rep.getAuth().setMethod(JWTClientAuthenticator.PROVIDER_ID);
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
int statusCode = response.getStatusLine().getStatusCode();
String body = EntityUtils.toString(response.getEntity());
assertEquals(200, statusCode, "Expected 200 but got " + statusCode + ": " + body);
OIDCClientRepresentation updated = mapper.readValue(body, OIDCClientRepresentation.class);
assertThat(updated.getAuth().getMethod(), is(JWTClientAuthenticator.PROVIDER_ID));
}
}
/**
* Test that updating a client via PATCH (merge patch) with an unacceptable client authenticator fails.
*/
@Test
public void updateClientViaPatchWithUnacceptableAuthType() throws Exception {
// First create a client with acceptable auth type
HttpPut createRequest = new HttpPut(HOSTNAME_LOCAL_ADMIN + "/test-patch-update-client");
setAuthHeader(createRequest);
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
OIDCClientRepresentation rep = new OIDCClientRepresentation();
rep.setClientId("test-patch-update-client");
rep.setEnabled(true);
var auth = new OIDCClientRepresentation.Auth();
auth.setMethod(JWTClientSecretAuthenticator.PROVIDER_ID);
auth.setSecret("secret");
rep.setAuth(auth);
createRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(createRequest)) {
assertEquals(201, response.getStatusLine().getStatusCode());
EntityUtils.consumeQuietly(response.getEntity());
}
// Now setup policy
setupPolicyClientIdAndSecretNotAcceptable();
// Try to patch the client to use an unacceptable auth type
HttpPatch patchRequest = new HttpPatch(HOSTNAME_LOCAL_ADMIN + "/test-patch-update-client");
setAuthHeader(patchRequest);
patchRequest.setHeader(HttpHeaders.CONTENT_TYPE, AdminApi.CONTENT_TYPE_MERGE_PATCH);
OIDCClientRepresentation patch = new OIDCClientRepresentation();
var patchAuth = new OIDCClientRepresentation.Auth();
patchAuth.setMethod(ClientIdAndSecretAuthenticator.PROVIDER_ID);
patch.setAuth(patchAuth);
patchRequest.setEntity(new StringEntity(mapper.writeValueAsString(patch)));
try (var response = client.execute(patchRequest)) {
// Should fail with 400 Bad Request due to policy violation
assertEquals(400, response.getStatusLine().getStatusCode());
String body = EntityUtils.toString(response.getEntity());
assertThat(body, containsString("invalid_client_metadata"));
}
}
/**
* Test that policy is applied during client update even when not changing the auth type.
* Creates the client before setting up the policy to avoid the initial creation issue.
*/
@Test
public void policyAppliedOnUpdateWithoutAuthTypeChange() throws Exception {
// Create a client BEFORE the policy is set
HttpPut createRequest = new HttpPut(HOSTNAME_LOCAL_ADMIN + "/test-put-update-client");
setAuthHeader(createRequest);
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
OIDCClientRepresentation rep = new OIDCClientRepresentation();
rep.setClientId("test-put-update-client");
rep.setEnabled(true);
var auth = new OIDCClientRepresentation.Auth();
auth.setMethod(JWTClientSecretAuthenticator.PROVIDER_ID);
auth.setSecret("secret");
rep.setAuth(auth);
createRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(createRequest)) {
assertEquals(201, response.getStatusLine().getStatusCode());
EntityUtils.consumeQuietly(response.getEntity());
}
// Now setup policy
setupPolicyClientIdAndSecretNotAcceptable();
// Update client without changing auth type (just change description)
HttpPut updateRequest = new HttpPut(HOSTNAME_LOCAL_ADMIN + "/test-put-update-client");
setAuthHeader(updateRequest);
updateRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
rep.setDescription("Updated description");
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
// Should succeed since auth type is still acceptable
int statusCode = response.getStatusLine().getStatusCode();
String body = EntityUtils.toString(response.getEntity());
assertEquals(200, statusCode, "Expected 200 but got " + statusCode + ": " + body);
OIDCClientRepresentation updated = mapper.readValue(body, OIDCClientRepresentation.class);
assertThat(updated.getDescription(), is("Updated description"));
assertThat(updated.getAuth().getMethod(), is(JWTClientSecretAuthenticator.PROVIDER_ID));
}
}
/**
* Sets up a policy that does NOT allow client_id and secret authenticator.
* Only JWT-based authenticators are allowed.
*/
private void setupPolicyClientIdAndSecretNotAcceptable() throws Exception {
setupPolicy("Test Profile/Policy that restricts client authenticators");
}
/**
* Sets up a policy with auto-configuration that defaults to X509 authenticator.
*/
private void setupPolicyWithAutoConfiguration() throws Exception {
setupPolicy("Test Profile/Policy with auto-configuration - defaults to X509",
config -> config.setDefaultClientAuthenticator(X509ClientAuthenticator.PROVIDER_ID));
}
private void setupPolicy(String description) throws Exception {
setupPolicy(description, PROFILE_NAME, POLICY_NAME, (config) -> {
});
}
private void setupPolicy(String description, Consumer<SecureClientAuthenticatorExecutor.Configuration> configuration) throws Exception {
setupPolicy(description, PROFILE_NAME, POLICY_NAME, configuration);
}
private void setupPolicy(String description, String profileName, String policyName, Consumer<SecureClientAuthenticatorExecutor.Configuration> configuration) throws Exception {
// Create profile
ClientProfileRepresentation profileRep = new ClientProfileRepresentation();
profileRep.setName(profileName);
profileRep.setDescription(description);
profileRep.setExecutors(new ArrayList<>());
ClientPolicyExecutorRepresentation executorRep = new ClientPolicyExecutorRepresentation();
executorRep.setExecutorProviderId(SecureClientAuthenticatorExecutorFactory.PROVIDER_ID);
SecureClientAuthenticatorExecutor.Configuration config = new SecureClientAuthenticatorExecutor.Configuration();
config.setAllowedClientAuthenticators(Arrays.asList(
JWTClientAuthenticator.PROVIDER_ID,
JWTClientSecretAuthenticator.PROVIDER_ID,
X509ClientAuthenticator.PROVIDER_ID
));
configuration.accept(config);
// Use JsonSerialization mapper to properly serialize with @JsonProperty annotations
JsonNode configNode = JsonSerialization.mapper.readValue(
JsonSerialization.mapper.writeValueAsBytes(config), JsonNode.class);
executorRep.setConfiguration(configNode);
profileRep.getExecutors().add(executorRep);
ClientProfilesRepresentation profilesRep = new ClientProfilesRepresentation();
profilesRep.setProfiles(List.of(profileRep));
adminClient.realm("master").clientPoliciesProfilesResource().updateProfiles(profilesRep);
// Create policy
ClientPolicyRepresentation policyRep = new ClientPolicyRepresentation();
policyRep.setName(policyName);
policyRep.setDescription(description);
policyRep.setEnabled(true);
policyRep.setProfiles(List.of(profileName));
policyRep.setConditions(new ArrayList<>());
// Add condition for authenticated user context
ClientPolicyConditionRepresentation conditionRep = new ClientPolicyConditionRepresentation();
conditionRep.setConditionProviderId(ClientUpdaterContextConditionFactory.PROVIDER_ID);
ClientPolicyConditionConfigurationRepresentation conditionConfig = new ClientPolicyConditionConfigurationRepresentation();
conditionConfig.setConfigAsMap(
ClientUpdaterContextConditionFactory.UPDATE_CLIENT_SOURCE,
List.of(ClientUpdaterContextConditionFactory.BY_AUTHENTICATED_USER)
);
JsonNode conditionConfigNode = JsonSerialization.mapper.readValue(
JsonSerialization.mapper.writeValueAsBytes(conditionConfig), JsonNode.class);
conditionRep.setConfiguration(conditionConfigNode);
policyRep.getConditions().add(conditionRep);
ClientPoliciesRepresentation policiesRep = new ClientPoliciesRepresentation();
policiesRep.setPolicies(List.of(policyRep));
adminClient.realm("master").clientPoliciesPoliciesResource().updatePolicies(policiesRep);
}
private void revertToBuiltinProfiles() {
try {
ClientProfilesRepresentation emptyProfiles = new ClientProfilesRepresentation();
emptyProfiles.setProfiles(List.of());
adminClient.realm("master").clientPoliciesProfilesResource().updateProfiles(emptyProfiles);
} catch (Exception e) {
// Ignore cleanup errors
}
}
private void revertToBuiltinPolicies() {
try {
ClientPoliciesRepresentation emptyPolicies = new ClientPoliciesRepresentation();
emptyPolicies.setPolicies(List.of());
adminClient.realm("master").clientPoliciesPoliciesResource().updatePolicies(emptyPolicies);
} catch (Exception e) {
// Ignore cleanup errors
}
}
private void cleanupClient(String clientId) {
try {
HttpDelete deleteRequest = new HttpDelete(HOSTNAME_LOCAL_ADMIN + "/" + clientId);
setAuthHeader(deleteRequest);
try (var response = client.execute(deleteRequest)) {
EntityUtils.consumeQuietly(response.getEntity());
}
} catch (Exception e) {
// Ignore cleanup errors
}
}
private void setAuthHeader(HttpMessage request) {
String token = adminClient.tokenManager().getAccessTokenString();
request.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
}
public static class AdminV2Config implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.features(Profile.Feature.CLIENT_ADMIN_API_V2);
}
}
}