diff --git a/rest/admin-v2/tests/pom.xml b/rest/admin-v2/tests/pom.xml
index 55331e21ca8..acb636e8f56 100644
--- a/rest/admin-v2/tests/pom.xml
+++ b/rest/admin-v2/tests/pom.xml
@@ -46,6 +46,17 @@
keycloak-tests-custom-providers
${project.version}
+
+ org.keycloak.tests
+ keycloak-tests-utils
+ ${project.version}
+ test
+
+
+ org.keycloak
+ keycloak-core
+ test
+
@@ -61,4 +72,4 @@
-
\ No newline at end of file
+
diff --git a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/InteropTest.java b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/InteropTest.java
new file mode 100644
index 00000000000..378bb4cb3f7
--- /dev/null
+++ b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/InteropTest.java
@@ -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 roles = realm.clients().get(clientUuid).roles().list();
+ Set 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);
+ }
+ }
+}
diff --git a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/mapper/ClientRepresentationComparator.java b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/mapper/ClientRepresentationComparator.java
new file mode 100644
index 00000000000..47847f7d57e
--- /dev/null
+++ b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/mapper/ClientRepresentationComparator.java
@@ -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 buildExpectedFlows() {
+ Set 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 getAuthField(OIDCClientRepresentation oidc, Function getter) {
+ return oidc.getAuth() != null ? getter.apply(oidc.getAuth()) : null;
+ }
+
+ private void compareSAMLFields(SAMLClientRepresentation saml) {
+ compare("frontchannelLogout→frontChannelLogout", v1.isFrontchannelLogout(), saml.getFrontChannelLogout());
+
+ Map 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 void compareAsSet(String fieldName, Collection v1Coll, Collection v2Coll) {
+ Set v1Set = v1Coll != null ? new HashSet<>(v1Coll) : Set.of();
+ Set 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 nullIfEmpty(Collection collection) {
+ return collection != null && !collection.isEmpty() ? (T) collection : null;
+ }
+
+ public static class ComparisonResult {
+ private final List mismatches = new ArrayList<>();
+ private final List matches = new ArrayList<>();
+ private final List 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 getMismatches() { return mismatches; }
+ public List getMatches() { return matches; }
+ public List 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 items) {
+ if (!items.isEmpty()) {
+ sb.append(title).append(":\n");
+ items.forEach(item -> sb.append(" - ").append(item).append("\n"));
+ }
+ }
+ }
+}