mirror of
https://github.com/keycloak/keycloak.git
synced 2026-06-09 09:04:21 -04:00
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:
parent
bce957f9a1
commit
0466955604
3 changed files with 778 additions and 1 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue