added test that verifies that Clients are created/updated with v1 are read correctly with v2 (#46551)

* added test that verifies that Clients are created/updated with v1 are read correctly with v2

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

* moved cleanup to finally block and removed debug statements

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

* refactored to make it more simple

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

* Update rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/InteropTest.java

Co-authored-by: Martin Bartoš <mabartos@redhat.com>
Signed-off-by: Erik Jan de Wit <edewit@redhat.com>

* Update rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/InteropTest.java

Co-authored-by: Martin Bartoš <mabartos@redhat.com>
Signed-off-by: Erik Jan de Wit <edewit@redhat.com>

* Update rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/mapper/ClientRepresentationComparator.java

Co-authored-by: Václav Muzikář <vaclav@muzikari.cz>
Signed-off-by: Erik Jan de Wit <edewit@redhat.com>

* add saml tests

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

* refactored and PR comments

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

* use constants instead of strings

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

* added roles test

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

* copilot review

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: Erik Jan de Wit <edewit@redhat.com>
Co-authored-by: Martin Bartoš <mabartos@redhat.com>
Co-authored-by: Václav Muzikář <vaclav@muzikari.cz>
This commit is contained in:
Erik Jan de Wit 2026-03-16 10:11:00 +01:00 committed by GitHub
parent bce957f9a1
commit 0466955604
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 778 additions and 1 deletions

View file

@ -46,6 +46,17 @@
<artifactId>keycloak-tests-custom-providers</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak.tests</groupId>
<artifactId>keycloak-tests-utils</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@ -61,4 +72,4 @@
</plugin>
</plugins>
</build>
</project>
</project>

View file

@ -0,0 +1,531 @@
/*
* 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.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.Profile;
import org.keycloak.representations.admin.v2.OIDCClientRepresentation;
import org.keycloak.representations.admin.v2.SAMLClientRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
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.testframework.util.ApiUtil;
import org.keycloak.tests.admin.mapper.ClientRepresentationComparator;
import org.apache.http.client.methods.HttpGet;
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.junit.jupiter.api.Test;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_ASSERTION_SIGNATURE;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_AUTHNSTATEMENT;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_FORCE_NAME_ID_FORMAT_ATTRIBUTE;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_FORCE_POST_BINDING;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_NAME_ID_FORMAT_ATTRIBUTE;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_SERVER_SIGNATURE;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_SIGNATURE_ALGORITHM;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* This class tests the interoperability between v1 and v2 admin APIs.
* It verifies that clients created/updated via one API version can be correctly
* read and validated via the other API version.
*/
@KeycloakIntegrationTest(config = InteropTest.ServerConfig.class)
public class InteropTest extends AbstractClientApiV2Test {
@InjectHttpClient
CloseableHttpClient httpClient;
@InjectAdminClient
Keycloak adminClient;
/**
* Test: Create a client using v1 API, then assert/read using v2 API.
*/
@Test
public void createWithV1AssertWithV2() throws Exception {
RealmResource realm = adminClient.realm("master");
ClientRepresentation v1Client = new ClientRepresentation();
v1Client.setClientId("v1-created-client");
v1Client.setName("V1 Created Client");
v1Client.setDescription("Client created via v1 API");
v1Client.setEnabled(true);
v1Client.setPublicClient(false);
v1Client.setProtocol("openid-connect");
v1Client.setBaseUrl("http://localhost:3000");
v1Client.setRedirectUris(Arrays.asList("http://localhost:3000/*", "http://localhost:3001/*"));
v1Client.setWebOrigins(Arrays.asList("http://localhost:3000", "http://localhost:3001"));
v1Client.setStandardFlowEnabled(true);
v1Client.setDirectAccessGrantsEnabled(true);
v1Client.setServiceAccountsEnabled(false);
v1Client.setClientAuthenticatorType("client-secret");
v1Client.setSecret("test-secret-123");
Response response = realm.clients().create(v1Client);
String clientUuid = ApiUtil.getCreatedId(response);
response.close();
ClientRepresentation createdV1Client = realm.clients().get(clientUuid).toRepresentation();
HttpGet getRequest = new HttpGet(getClientsApiUrl() + "/v1-created-client");
setAuthHeader(getRequest, adminClient);
try (var httpResponse = httpClient.execute(getRequest)) {
assertThat(httpResponse.getStatusLine().getStatusCode(), is(200));
OIDCClientRepresentation v2Client = mapper.createParser(httpResponse.getEntity().getContent())
.readValueAs(OIDCClientRepresentation.class);
ClientRepresentationComparator.ComparisonResult result =
ClientRepresentationComparator.compare(createdV1Client, v2Client);
assertTrue(result.allMatch(), "V1 and V2 representations should match:\n" + result);
} finally {
realm.clients().get(clientUuid).remove();
}
}
/**
* Test: Create a client using v2 API, then assert/read using v1 API.
*/
@Test
public void createWithV2AssertWithV1() throws Exception {
RealmResource realm = adminClient.realm("master");
OIDCClientRepresentation v2Client = new OIDCClientRepresentation();
v2Client.setClientId("v2-created-client");
v2Client.setDisplayName("V2 Created Client");
v2Client.setDescription("Client created via v2 API");
v2Client.setEnabled(true);
v2Client.setAppUrl("http://localhost:4000");
v2Client.setRedirectUris(Set.of("http://localhost:4000/*", "http://localhost:4001/*"));
v2Client.setWebOrigins(Set.of("http://localhost:4000", "http://localhost:4001"));
v2Client.setLoginFlows(Set.of(
OIDCClientRepresentation.Flow.STANDARD,
OIDCClientRepresentation.Flow.DIRECT_GRANT
));
OIDCClientRepresentation.Auth auth = new OIDCClientRepresentation.Auth();
auth.setMethod("client-secret");
auth.setSecret("v2-secret-456");
v2Client.setAuth(auth);
HttpPost createRequest = new HttpPost(getClientsApiUrl());
setAuthHeader(createRequest, adminClient);
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
createRequest.setEntity(new StringEntity(mapper.writeValueAsString(v2Client)));
try (var httpResponse = httpClient.execute(createRequest)) {
assertThat(httpResponse.getStatusLine().getStatusCode(), is(201));
}
ClientRepresentation v1Client = realm.clients().findByClientId("v2-created-client").get(0);
String clientUuid = v1Client.getId();
ClientRepresentation fullV1Client = realm.clients().get(clientUuid).toRepresentation();
HttpGet getRequest = new HttpGet(getClientsApiUrl() + "/v2-created-client");
setAuthHeader(getRequest, adminClient);
try (var httpResponse = httpClient.execute(getRequest)) {
assertThat(httpResponse.getStatusLine().getStatusCode(), is(200));
OIDCClientRepresentation fetchedV2Client = mapper.createParser(httpResponse.getEntity().getContent())
.readValueAs(OIDCClientRepresentation.class);
ClientRepresentationComparator.ComparisonResult result =
ClientRepresentationComparator.compare(fullV1Client, fetchedV2Client);
assertTrue(result.allMatch(), "V1 and V2 representations should match:\n" + result);
} finally {
realm.clients().get(clientUuid).remove();
}
}
/**
* Test: Create a client using v1 API, update it using v2 API, then assert using v1 API.
*/
@Test
public void updateWithV2AssertWithV1() throws Exception {
RealmResource realm = adminClient.realm("master");
ClientRepresentation v1Client = new ClientRepresentation();
v1Client.setClientId("update-test-client");
v1Client.setName("Original Name");
v1Client.setDescription("Original description");
v1Client.setEnabled(true);
v1Client.setPublicClient(false);
v1Client.setProtocol("openid-connect");
v1Client.setRedirectUris(Arrays.asList("http://localhost:5000/*"));
v1Client.setStandardFlowEnabled(true);
Response response = realm.clients().create(v1Client);
String clientUuid = ApiUtil.getCreatedId(response);
response.close();
OIDCClientRepresentation v2Update = new OIDCClientRepresentation();
v2Update.setClientId("update-test-client");
v2Update.setDisplayName("Updated Name via V2");
v2Update.setDescription("Updated description via V2 API");
v2Update.setEnabled(true);
v2Update.setAppUrl("http://localhost:5000");
v2Update.setRedirectUris(Set.of("http://localhost:5000/*", "http://localhost:5001/*"));
v2Update.setWebOrigins(Set.of("http://localhost:5000"));
v2Update.setLoginFlows(Set.of(
OIDCClientRepresentation.Flow.STANDARD,
OIDCClientRepresentation.Flow.DIRECT_GRANT,
OIDCClientRepresentation.Flow.SERVICE_ACCOUNT
));
OIDCClientRepresentation.Auth auth = new OIDCClientRepresentation.Auth();
auth.setMethod("client-secret");
auth.setSecret("updated-secret");
v2Update.setAuth(auth);
HttpPut updateRequest = new HttpPut(getClientsApiUrl() + "/update-test-client");
setAuthHeader(updateRequest, adminClient);
updateRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(v2Update)));
try (var httpResponse = httpClient.execute(updateRequest)) {
assertThat(httpResponse.getStatusLine().getStatusCode(), is(200));
}
ClientRepresentation updatedV1Client = realm.clients().get(clientUuid).toRepresentation();
HttpGet getRequest = new HttpGet(getClientsApiUrl() + "/update-test-client");
setAuthHeader(getRequest, adminClient);
try (var httpResponse = httpClient.execute(getRequest)) {
assertThat(httpResponse.getStatusLine().getStatusCode(), is(200));
OIDCClientRepresentation fetchedV2Client = mapper.createParser(httpResponse.getEntity().getContent())
.readValueAs(OIDCClientRepresentation.class);
ClientRepresentationComparator.ComparisonResult result =
ClientRepresentationComparator.compare(updatedV1Client, fetchedV2Client);
assertTrue(result.allMatch(), "V1 and V2 representations should match after update:\n" + result);
assertThat(updatedV1Client.getName(), is("Updated Name via V2"));
assertThat(updatedV1Client.getDescription(), is("Updated description via V2 API"));
assertThat(updatedV1Client.isServiceAccountsEnabled(), is(true));
} finally {
realm.clients().get(clientUuid).remove();
}
}
/**
* Test: Create a SAML client using v1 API, then assert/read using v2 API.
*/
@Test
public void createSamlWithV1AssertWithV2() throws Exception {
RealmResource realm = adminClient.realm("master");
ClientRepresentation v1Client = new ClientRepresentation();
v1Client.setClientId("v1-saml-client");
v1Client.setName("V1 SAML Client");
v1Client.setDescription("SAML client created via v1 API");
v1Client.setEnabled(true);
v1Client.setProtocol("saml");
v1Client.setBaseUrl("http://localhost:8000/saml");
v1Client.setRedirectUris(Arrays.asList("http://localhost:8000/saml/*"));
v1Client.setFrontchannelLogout(true);
v1Client.setAttributes(java.util.Map.of(
SAML_NAME_ID_FORMAT_ATTRIBUTE, "username",
SAML_FORCE_NAME_ID_FORMAT_ATTRIBUTE, "true",
SAML_AUTHNSTATEMENT, "true",
SAML_SERVER_SIGNATURE, "true",
SAML_ASSERTION_SIGNATURE, "false",
SAML_CLIENT_SIGNATURE_ATTRIBUTE, "false",
SAML_FORCE_POST_BINDING, "true",
SAML_SIGNATURE_ALGORITHM, "RSA_SHA256"
));
Response response = realm.clients().create(v1Client);
String clientUuid = ApiUtil.getCreatedId(response);
response.close();
ClientRepresentation createdV1Client = realm.clients().get(clientUuid).toRepresentation();
HttpGet getRequest = new HttpGet(getClientsApiUrl() + "/v1-saml-client");
setAuthHeader(getRequest, adminClient);
try (var httpResponse = httpClient.execute(getRequest)) {
assertThat(httpResponse.getStatusLine().getStatusCode(), is(200));
SAMLClientRepresentation v2Client = mapper.createParser(httpResponse.getEntity().getContent())
.readValueAs(SAMLClientRepresentation.class);
ClientRepresentationComparator.ComparisonResult result =
ClientRepresentationComparator.compare(createdV1Client, v2Client);
assertTrue(result.allMatch(), "V1 and V2 SAML representations should match:\n" + result);
} finally {
realm.clients().get(clientUuid).remove();
}
}
/**
* Test: Create a SAML client using v2 API, then assert/read using v1 API.
*/
@Test
public void createSamlWithV2AssertWithV1() throws Exception {
RealmResource realm = adminClient.realm("master");
SAMLClientRepresentation v2Client = new SAMLClientRepresentation();
v2Client.setClientId("v2-saml-client");
v2Client.setDisplayName("V2 SAML Client");
v2Client.setDescription("SAML client created via v2 API");
v2Client.setEnabled(true);
v2Client.setAppUrl("http://localhost:9000/saml");
v2Client.setRedirectUris(Set.of("http://localhost:9000/saml/*"));
v2Client.setFrontChannelLogout(true);
v2Client.setNameIdFormat("email");
v2Client.setForceNameIdFormat(true);
v2Client.setIncludeAuthnStatement(true);
v2Client.setSignDocuments(true);
v2Client.setSignAssertions(true);
v2Client.setClientSignatureRequired(false);
v2Client.setForcePostBinding(true);
v2Client.setSignatureAlgorithm("RSA_SHA256");
HttpPost createRequest = new HttpPost(getClientsApiUrl());
setAuthHeader(createRequest, adminClient);
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
createRequest.setEntity(new StringEntity(mapper.writeValueAsString(v2Client)));
try (var httpResponse = httpClient.execute(createRequest)) {
assertThat(httpResponse.getStatusLine().getStatusCode(), is(201));
}
ClientRepresentation v1Client = realm.clients().findByClientId("v2-saml-client").get(0);
String clientUuid = v1Client.getId();
ClientRepresentation fullV1Client = realm.clients().get(clientUuid).toRepresentation();
HttpGet getRequest = new HttpGet(getClientsApiUrl() + "/v2-saml-client");
setAuthHeader(getRequest, adminClient);
try (var httpResponse = httpClient.execute(getRequest)) {
assertThat(httpResponse.getStatusLine().getStatusCode(), is(200));
SAMLClientRepresentation fetchedV2Client = mapper.createParser(httpResponse.getEntity().getContent())
.readValueAs(SAMLClientRepresentation.class);
ClientRepresentationComparator.ComparisonResult result =
ClientRepresentationComparator.compare(fullV1Client, fetchedV2Client);
assertTrue(result.allMatch(), "V1 and V2 SAML representations should match:\n" + result);
} finally {
realm.clients().get(clientUuid).remove();
}
}
/**
* Test: Create a SAML client using v1 API, update it using v2 API, then assert using v1 API.
*/
@Test
public void updateSamlWithV2AssertWithV1() throws Exception {
RealmResource realm = adminClient.realm("master");
ClientRepresentation v1Client = new ClientRepresentation();
v1Client.setClientId("update-saml-client");
v1Client.setName("Original SAML Name");
v1Client.setDescription("Original SAML description");
v1Client.setEnabled(true);
v1Client.setProtocol("saml");
v1Client.setRedirectUris(Arrays.asList("http://localhost:7000/saml/*"));
v1Client.setAttributes(java.util.Map.of(
"saml.server.signature", "false",
"saml.force.post.binding", "false"
));
Response response = realm.clients().create(v1Client);
String clientUuid = ApiUtil.getCreatedId(response);
response.close();
SAMLClientRepresentation v2Update = new SAMLClientRepresentation();
v2Update.setClientId("update-saml-client");
v2Update.setDisplayName("Updated SAML Name via V2");
v2Update.setDescription("Updated SAML description via V2 API");
v2Update.setEnabled(true);
v2Update.setAppUrl("http://localhost:7000/saml");
v2Update.setRedirectUris(Set.of("http://localhost:7000/saml/*", "http://localhost:7001/saml/*"));
v2Update.setFrontChannelLogout(true);
v2Update.setSignDocuments(true);
v2Update.setSignAssertions(true);
v2Update.setForcePostBinding(true);
v2Update.setSignatureAlgorithm("RSA_SHA512");
HttpPut updateRequest = new HttpPut(getClientsApiUrl() + "/update-saml-client");
setAuthHeader(updateRequest, adminClient);
updateRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(v2Update)));
try (var httpResponse = httpClient.execute(updateRequest)) {
assertThat(httpResponse.getStatusLine().getStatusCode(), is(200));
}
ClientRepresentation updatedV1Client = realm.clients().get(clientUuid).toRepresentation();
HttpGet getRequest = new HttpGet(getClientsApiUrl() + "/update-saml-client");
setAuthHeader(getRequest, adminClient);
try (var httpResponse = httpClient.execute(getRequest)) {
assertThat(httpResponse.getStatusLine().getStatusCode(), is(200));
SAMLClientRepresentation fetchedV2Client = mapper.createParser(httpResponse.getEntity().getContent())
.readValueAs(SAMLClientRepresentation.class);
ClientRepresentationComparator.ComparisonResult result =
ClientRepresentationComparator.compare(updatedV1Client, fetchedV2Client);
assertTrue(result.allMatch(), "V1 and V2 SAML representations should match after update:\n" + result);
assertThat(updatedV1Client.getName(), is("Updated SAML Name via V2"));
assertThat(updatedV1Client.getDescription(), is("Updated SAML description via V2 API"));
assertThat(updatedV1Client.getAttributes().get("saml.server.signature"), is("true"));
assertThat(updatedV1Client.getAttributes().get("saml.signature.algorithm"), is("RSA_SHA512"));
} finally {
realm.clients().get(clientUuid).remove();
}
}
/**
* Test: Create a client with roles using v2 API, then assert roles using v1 API.
*/
@Test
public void createWithV2RolesAssertWithV1() throws Exception {
RealmResource realm = adminClient.realm("master");
OIDCClientRepresentation v2Client = new OIDCClientRepresentation();
v2Client.setClientId("v2-client-with-roles");
v2Client.setDisplayName("V2 Client With Roles");
v2Client.setDescription("Client with roles created via v2 API");
v2Client.setEnabled(true);
v2Client.setRoles(Set.of("viewer", "editor", "admin"));
v2Client.setRedirectUris(Set.of("http://localhost:3000/*"));
v2Client.setLoginFlows(Set.of(OIDCClientRepresentation.Flow.STANDARD));
HttpPost createRequest = new HttpPost(getClientsApiUrl());
setAuthHeader(createRequest, adminClient);
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
createRequest.setEntity(new StringEntity(mapper.writeValueAsString(v2Client)));
try (var httpResponse = httpClient.execute(createRequest)) {
assertThat(httpResponse.getStatusLine().getStatusCode(), is(201));
}
ClientRepresentation v1Client = realm.clients().findByClientId("v2-client-with-roles").get(0);
String clientUuid = v1Client.getId();
try {
List<RoleRepresentation> roles = realm.clients().get(clientUuid).roles().list();
Set<String> roleNames = roles.stream()
.map(RoleRepresentation::getName)
.collect(Collectors.toSet());
assertThat(roleNames, containsInAnyOrder("viewer", "editor", "admin"));
HttpGet getRequest = new HttpGet(getClientsApiUrl() + "/v2-client-with-roles");
setAuthHeader(getRequest, adminClient);
try (var httpResponse = httpClient.execute(getRequest)) {
assertThat(httpResponse.getStatusLine().getStatusCode(), is(200));
OIDCClientRepresentation fetchedV2Client = mapper.createParser(httpResponse.getEntity().getContent())
.readValueAs(OIDCClientRepresentation.class);
assertThat(fetchedV2Client.getRoles(), containsInAnyOrder("viewer", "editor", "admin"));
}
} finally {
realm.clients().get(clientUuid).remove();
}
}
/**
* Test: Create a client using v1 API, add roles via v1 API, then assert roles using v2 API.
*/
@Test
public void createWithV1RolesAssertWithV2() throws Exception {
RealmResource realm = adminClient.realm("master");
ClientRepresentation v1Client = new ClientRepresentation();
v1Client.setClientId("v1-client-with-roles");
v1Client.setName("V1 Client With Roles");
v1Client.setDescription("Client with roles created via v1 API");
v1Client.setEnabled(true);
v1Client.setProtocol("openid-connect");
v1Client.setRedirectUris(Arrays.asList("http://localhost:4000/*"));
v1Client.setStandardFlowEnabled(true);
Response response = realm.clients().create(v1Client);
String clientUuid = ApiUtil.getCreatedId(response);
response.close();
try {
RoleRepresentation role1 = new RoleRepresentation();
role1.setName("read-access");
role1.setDescription("Read access role");
realm.clients().get(clientUuid).roles().create(role1);
RoleRepresentation role2 = new RoleRepresentation();
role2.setName("write-access");
role2.setDescription("Write access role");
realm.clients().get(clientUuid).roles().create(role2);
RoleRepresentation role3 = new RoleRepresentation();
role3.setName("delete-access");
realm.clients().get(clientUuid).roles().create(role3);
HttpGet getRequest = new HttpGet(getClientsApiUrl() + "/v1-client-with-roles");
setAuthHeader(getRequest, adminClient);
try (var httpResponse = httpClient.execute(getRequest)) {
assertThat(httpResponse.getStatusLine().getStatusCode(), is(200));
OIDCClientRepresentation fetchedV2Client = mapper.createParser(httpResponse.getEntity().getContent())
.readValueAs(OIDCClientRepresentation.class);
assertThat(fetchedV2Client.getRoles(), containsInAnyOrder("read-access", "write-access", "delete-access"));
}
} finally {
realm.clients().get(clientUuid).remove();
}
}
public static class ServerConfig implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.features(Profile.Feature.CLIENT_ADMIN_API_V2);
}
}
}

View file

@ -0,0 +1,235 @@
/*
* 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.mapper;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
import org.keycloak.representations.admin.v2.OIDCClientRepresentation;
import org.keycloak.representations.admin.v2.OIDCClientRepresentation.Flow;
import org.keycloak.representations.admin.v2.SAMLClientRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_ALLOW_ECP_FLOW;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_ASSERTION_SIGNATURE;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_AUTHNSTATEMENT;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_CANONICALIZATION_METHOD_ATTRIBUTE;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_CLIENT_SIGNATURE_ATTRIBUTE;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_FORCE_NAME_ID_FORMAT_ATTRIBUTE;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_FORCE_POST_BINDING;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_NAME_ID_FORMAT_ATTRIBUTE;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_SERVER_SIGNATURE;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_SIGNATURE_ALGORITHM;
import static org.keycloak.protocol.saml.SamlConfigAttributes.SAML_SIGNING_CERTIFICATE_ATTRIBUTE;
/**
* Utility class for comparing v1 ClientRepresentation against v2 client representations.
*/
public class ClientRepresentationComparator {
private final ComparisonResult result = new ComparisonResult();
private final ClientRepresentation v1;
private final BaseClientRepresentation v2;
private ClientRepresentationComparator(ClientRepresentation v1, BaseClientRepresentation v2) {
this.v1 = v1;
this.v2 = v2;
}
public static ComparisonResult compare(ClientRepresentation v1, BaseClientRepresentation v2) {
return new ClientRepresentationComparator(v1, v2).execute();
}
public static void assertMatches(ClientRepresentation v1, BaseClientRepresentation v2) {
ComparisonResult result = compare(v1, v2);
if (!result.allMatch()) {
throw new AssertionError("V1 and V2 representations do not match:\n" + result);
}
}
private ComparisonResult execute() {
compareBaseFields();
if (v2 instanceof OIDCClientRepresentation oidc) {
compareOIDCFields(oidc);
} else if (v2 instanceof SAMLClientRepresentation saml) {
compareSAMLFields(saml);
}
return result;
}
private void compareBaseFields() {
compare("clientId", v1.getClientId(), v2.getClientId());
compare("name→displayName", v1.getName(), v2.getDisplayName());
compare("description", v1.getDescription(), v2.getDescription());
compare("enabled", v1.isEnabled(), v2.getEnabled());
compare("baseUrl→appUrl", v1.getBaseUrl(), v2.getAppUrl());
compare("protocol", v1.getProtocol(), v2.getProtocol());
compareAsSet("redirectUris", v1.getRedirectUris(), v2.getRedirectUris());
result.addV2OnlyField("roles", nullIfEmpty(v2.getRoles()));
}
private void compareOIDCFields(OIDCClientRepresentation oidc) {
compareAsSet("webOrigins", v1.getWebOrigins(), oidc.getWebOrigins());
compare("flows→loginFlows", buildExpectedFlows(), oidc.getLoginFlows());
compareAuth(oidc);
result.addV2OnlyField("auth.certificate", getAuthField(oidc, OIDCClientRepresentation.Auth::getCertificate));
result.addV2OnlyField("serviceAccountRoles", nullIfEmpty(oidc.getServiceAccountRoles()));
}
private Set<Flow> buildExpectedFlows() {
Set<Flow> flows = new HashSet<>();
if (Boolean.TRUE.equals(v1.isStandardFlowEnabled())) flows.add(Flow.STANDARD);
if (Boolean.TRUE.equals(v1.isImplicitFlowEnabled())) flows.add(Flow.IMPLICIT);
if (Boolean.TRUE.equals(v1.isDirectAccessGrantsEnabled())) flows.add(Flow.DIRECT_GRANT);
if (Boolean.TRUE.equals(v1.isServiceAccountsEnabled())) flows.add(Flow.SERVICE_ACCOUNT);
return flows;
}
private void compareAuth(OIDCClientRepresentation oidc) {
String expectedMethod = determineExpectedAuthMethod();
compare("clientAuthenticatorType→auth.method", expectedMethod, getAuthField(oidc, OIDCClientRepresentation.Auth::getMethod));
compare("secret→auth.secret", v1.getSecret(), getAuthField(oidc, OIDCClientRepresentation.Auth::getSecret));
}
private String determineExpectedAuthMethod() {
if (Boolean.TRUE.equals(v1.isPublicClient())) {
return "none";
}
if (v1.getClientAuthenticatorType() != null) {
return v1.getClientAuthenticatorType();
}
if (v1.getSecret() != null) {
return "client-secret";
}
return null;
}
private <T> T getAuthField(OIDCClientRepresentation oidc, Function<OIDCClientRepresentation.Auth, T> getter) {
return oidc.getAuth() != null ? getter.apply(oidc.getAuth()) : null;
}
private void compareSAMLFields(SAMLClientRepresentation saml) {
compare("frontchannelLogout→frontChannelLogout", v1.isFrontchannelLogout(), saml.getFrontChannelLogout());
Map<String, String> attrs = v1.getAttributes();
if (attrs == null) return;
compare("attr[saml_name_id_format]→nameIdFormat", attrs.get(SAML_NAME_ID_FORMAT_ATTRIBUTE), saml.getNameIdFormat());
compareSamlBoolean(SAML_FORCE_NAME_ID_FORMAT_ATTRIBUTE, "forceNameIdFormat", saml.getForceNameIdFormat());
compareSamlBoolean(SAML_AUTHNSTATEMENT, "includeAuthnStatement", saml.getIncludeAuthnStatement());
compareSamlBoolean(SAML_SERVER_SIGNATURE, "signDocuments", saml.getSignDocuments());
compareSamlBoolean(SAML_ASSERTION_SIGNATURE, "signAssertions", saml.getSignAssertions());
compareSamlBoolean(SAML_CLIENT_SIGNATURE_ATTRIBUTE, "clientSignatureRequired", saml.getClientSignatureRequired());
compareSamlBoolean(SAML_FORCE_POST_BINDING, "forcePostBinding", saml.getForcePostBinding());
compareSamlBoolean(SAML_ALLOW_ECP_FLOW, "allowEcpFlow", saml.getAllowEcpFlow());
compare("attr[saml.signature.algorithm]→signatureAlgorithm", attrs.get(SAML_SIGNATURE_ALGORITHM), saml.getSignatureAlgorithm());
compare("attr[saml_signature_canonicalization_method]→signatureCanonicalizationMethod", attrs.get(SAML_CANONICALIZATION_METHOD_ATTRIBUTE), saml.getSignatureCanonicalizationMethod());
compare("attr[saml.signing.certificate]→signingCertificate", attrs.get(SAML_SIGNING_CERTIFICATE_ATTRIBUTE), saml.getSigningCertificate());
}
private void compareSamlBoolean(String attrName, String v2FieldName, Boolean v2Value) {
String attrValue = v1.getAttributes() != null ? v1.getAttributes().get(attrName) : null;
Boolean v1Value = attrValue != null ? Boolean.parseBoolean(attrValue) : null;
compare("attr[" + attrName + "]→" + v2FieldName, v1Value, v2Value);
}
private void compare(String fieldName, Object v1Value, Object v2Value) {
if (Objects.equals(v1Value, v2Value) || bothNullOrEmpty(v1Value, v2Value)) {
result.addMatch(fieldName, v1Value != null ? v1Value : "null/empty");
} else {
result.addMismatch(fieldName, v1Value, v2Value);
}
}
private <T> void compareAsSet(String fieldName, Collection<T> v1Coll, Collection<T> v2Coll) {
Set<T> v1Set = v1Coll != null ? new HashSet<>(v1Coll) : Set.of();
Set<T> v2Set = v2Coll != null ? new HashSet<>(v2Coll) : Set.of();
if (v1Set.equals(v2Set)) {
result.addMatch(fieldName, v1Set.isEmpty() ? "empty" : v1Set);
} else {
result.addMismatch(fieldName, v1Set, v2Set);
}
}
private static boolean bothNullOrEmpty(Object a, Object b) {
return (a == null && isEmpty(b)) || (b == null && isEmpty(a));
}
private static boolean isEmpty(Object value) {
if (value instanceof Collection<?> c) return c.isEmpty();
if (value instanceof Map<?, ?> m) return m.isEmpty();
return false;
}
private static <T> T nullIfEmpty(Collection<T> collection) {
return collection != null && !collection.isEmpty() ? (T) collection : null;
}
public static class ComparisonResult {
private final List<String> mismatches = new ArrayList<>();
private final List<String> matches = new ArrayList<>();
private final List<String> v2OnlyFields = new ArrayList<>();
public void addMismatch(String fieldName, Object v1Value, Object v2Value) {
mismatches.add(String.format("%s: v1='%s' vs v2='%s'", fieldName, v1Value, v2Value));
}
public void addMatch(String fieldName, Object value) {
matches.add(String.format("%s: '%s'", fieldName, value));
}
public void addV2OnlyField(String fieldName, Object value) {
if (value != null) {
v2OnlyFields.add(String.format("%s: '%s'", fieldName, value));
}
}
public boolean allMatch() {
return mismatches.isEmpty();
}
public List<String> getMismatches() { return mismatches; }
public List<String> getMatches() { return matches; }
public List<String> getV2OnlyFields() { return v2OnlyFields; }
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
appendSection(sb, "MISMATCHES", mismatches);
appendSection(sb, "MATCHES", matches);
appendSection(sb, "V2-ONLY FIELDS (not mapped from v1)", v2OnlyFields);
return sb.toString();
}
private void appendSection(StringBuilder sb, String title, List<String> items) {
if (!items.isEmpty()) {
sb.append(title).append(":\n");
items.forEach(item -> sb.append(" - ").append(item).append("\n"));
}
}
}
}