[admin-api-v2] Provide simple validation with Jakarta/Hibernate Validation (#41110)

Signed-off-by: Martin Bartoš <mabartos@redhat.com>
This commit is contained in:
Martin Bartoš 2025-08-04 16:25:02 +02:00
parent 9e1e0dbad3
commit eca1333027
22 changed files with 241 additions and 27 deletions

View File

@ -68,6 +68,10 @@
<groupId>org.eclipse.microprofile.openapi</groupId>
<artifactId>microprofile-openapi-api</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>

View File

@ -3,8 +3,11 @@ package org.keycloak.representations.admin.v2;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.hibernate.validator.constraints.URL;
import org.keycloak.representations.admin.v2.validation.CreateClient;
import java.util.LinkedHashSet;
import java.util.Set;
@ -13,6 +16,7 @@ public class ClientRepresentation extends BaseRepresentation {
public static final String OIDC = "openid-connect";
@NotBlank(groups = CreateClient.class)
@JsonPropertyDescription("ID uniquely identifying this client")
private String clientId;
@ -29,28 +33,31 @@ public class ClientRepresentation extends BaseRepresentation {
@JsonPropertyDescription("Whether this client is enabled")
private Boolean enabled;
@URL
@JsonPropertyDescription("URL to the application's homepage that is represented by this client")
private String appUrl;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonPropertyDescription("URLs that the browser can redirect to after login")
private Set<String> appRedirectUrls = new LinkedHashSet<String>();
private Set<@NotBlank @URL(message = "Each redirect URL must be valid") String> appRedirectUrls = new LinkedHashSet<String>();
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonPropertyDescription("Login flows that are enabled for this client")
private Set<String> loginFlows = new LinkedHashSet<String>();
private Set<@NotBlank String> loginFlows = new LinkedHashSet<String>();
@Valid
@JsonPropertyDescription("Authentication configuration for this client")
private Auth auth;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonPropertyDescription("Web origins that are allowed to make requests to this client")
private Set<String> webOrigins = new LinkedHashSet<String>();
private Set<@NotBlank String> webOrigins = new LinkedHashSet<String>();
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonPropertyDescription("Roles associated with this client")
private Set<String> roles = new LinkedHashSet<String>();
private Set<@NotBlank String> roles = new LinkedHashSet<String>();
@Valid
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonPropertyDescription("Service account configuration for this client")
private ServiceAccount serviceAccount;
@ -160,6 +167,7 @@ public class ClientRepresentation extends BaseRepresentation {
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public static class Auth {
@NotNull
@JsonPropertyDescription("Whether authentication is enabled for this client")
private Boolean enabled;
@ -208,6 +216,7 @@ public class ClientRepresentation extends BaseRepresentation {
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public static class ServiceAccount {
@NotNull
@JsonPropertyDescription("Whether the service account is enabled")
private Boolean enabled;

View File

@ -0,0 +1,5 @@
package org.keycloak.representations.admin.v2.validation;
// Jakarta Validation Group - validation is done only when creating a client
public interface CreateClient {
}

17
pom.xml
View File

@ -91,6 +91,8 @@
<hibernate-orm.plugin.version>6.2.13.Final</hibernate-orm.plugin.version>
<hibernate.c3p0.version>6.2.13.Final</hibernate.c3p0.version>
<infinispan.version>15.0.19.Final</infinispan.version>
<hibernate-validator.version>9.0.1.Final</hibernate-validator.version>
<expressly.version>6.0.0</expressly.version>
<protostream.version>5.0.14.Final</protostream.version> <!-- For the annotation processor: keep in sync with the version shipped with Infinispan -->
<protostream.plugin.version>${protostream.version}</protostream.plugin.version>
@ -584,6 +586,21 @@
<artifactId>h2</artifactId>
<version>${h2.version}</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>${hibernate-validator.version}</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator-cdi</artifactId>
<version>${hibernate-validator.version}</version>
</dependency>
<dependency>
<groupId>org.glassfish.expressly</groupId>
<artifactId>expressly</artifactId>
<version>${expressly.version}</version>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-c3p0</artifactId>

View File

@ -138,6 +138,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson-deployment</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator-deployment</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-deployment</artifactId>

View File

@ -140,6 +140,7 @@ import org.keycloak.transaction.JBossJtaTransactionManagerLookup;
import org.keycloak.userprofile.config.UPConfigUtils;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.StringUtil;
import org.keycloak.validation.jakarta.HibernateValidatorProviderFactory;
import org.keycloak.vault.FilesKeystoreVaultProviderFactory;
import org.keycloak.vault.FilesPlainTextVaultProviderFactory;
@ -197,6 +198,7 @@ class KeycloakProcessor {
JBossJtaTransactionManagerLookup.class,
DefaultJpaConnectionProviderFactory.class,
DefaultLiquibaseConnectionProvider.class,
//HibernateValidatorProviderFactory.class,
FolderThemeProviderFactory.class,
LiquibaseJpaUpdaterProviderFactory.class,
FilesKeystoreVaultProviderFactory.class,

View File

@ -140,6 +140,12 @@
<artifactId>mapstruct</artifactId>
</dependency>
<!-- Hibernate validator -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
<!-- SmallRye -->
<dependency>
<groupId>io.smallrye.config</groupId>

View File

@ -1,11 +1,13 @@
package org.keycloak.admin.api.client;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PATCH;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import org.keycloak.admin.api.FieldValidation;
@ -25,11 +27,12 @@ public interface ClientApi {
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
ClientRepresentation createOrUpdateClient(ClientRepresentation client, @PathParam("fieldValidation") FieldValidation fieldValidation);
ClientRepresentation createOrUpdateClient(@Valid ClientRepresentation client,
@QueryParam("fieldValidation") FieldValidation fieldValidation);
@PATCH
@Consumes({MediaType.APPLICATION_JSON_PATCH_JSON, CONENT_TYPE_MERGE_PATCH})
@Produces(MediaType.APPLICATION_JSON)
ClientRepresentation patchClient(JsonNode patch, @PathParam("fieldValidation") FieldValidation fieldValidation);
ClientRepresentation patchClient(JsonNode patch, @QueryParam("fieldValidation") FieldValidation fieldValidation);
}

View File

@ -2,6 +2,9 @@ package org.keycloak.admin.api.client;
import java.util.stream.Stream;
import jakarta.validation.Valid;
import jakarta.validation.groups.ConvertGroup;
import jakarta.ws.rs.QueryParam;
import org.keycloak.admin.api.FieldValidation;
import org.keycloak.provider.Provider;
import org.keycloak.representations.admin.v2.ClientRepresentation;
@ -13,6 +16,7 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.keycloak.representations.admin.v2.validation.CreateClient;
public interface ClientsApi extends Provider {
@ -24,7 +28,8 @@ public interface ClientsApi extends Provider {
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
ClientRepresentation createClient(ClientRepresentation client, @PathParam("fieldValidation") FieldValidation fieldValidation);
ClientRepresentation createClient(@Valid @ConvertGroup(to = CreateClient.class) ClientRepresentation client,
@QueryParam("fieldValidation") FieldValidation fieldValidation);
@Path("{id}")
ClientApi client(@PathParam("id") String id);

View File

@ -2,11 +2,14 @@ package org.keycloak.admin.api.client;
import java.util.stream.Stream;
import jakarta.validation.Valid;
import jakarta.validation.groups.ConvertGroup;
import org.keycloak.admin.api.FieldValidation;
import org.keycloak.http.HttpResponse;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.representations.admin.v2.validation.CreateClient;
import org.keycloak.services.ServiceException;
import org.keycloak.services.client.ClientService;
@ -35,7 +38,8 @@ public class DefaultClientsApi implements ClientsApi {
}
@Override
public ClientRepresentation createClient(ClientRepresentation client, FieldValidation fieldValidation) {
public ClientRepresentation createClient(@Valid @ConvertGroup(to = CreateClient.class) ClientRepresentation client,
FieldValidation fieldValidation) {
try {
response.setStatus(Response.Status.CREATED.getStatusCode());
return clientService.createOrUpdate(realm, client, false).representation();

View File

@ -0,0 +1,9 @@
package org.keycloak.validation.jakarta;
import jakarta.validation.Validator;
import org.keycloak.provider.Provider;
public interface JakartaValidatorProvider extends Provider {
Validator getValidator();
}

View File

@ -0,0 +1,6 @@
package org.keycloak.validation.jakarta;
import org.keycloak.provider.ProviderFactory;
public interface JakartaValidatorProviderFactory extends ProviderFactory<JakartaValidatorProvider> {
}

View File

@ -0,0 +1,27 @@
package org.keycloak.validation.jakarta;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class JakartaValidatorSpi implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "jakarta-validator";
}
@Override
public Class<? extends Provider> getProviderClass() {
return JakartaValidatorProvider.class;
}
@Override
public Class<? extends ProviderFactory<?>> getProviderFactoryClass() {
return JakartaValidatorProviderFactory.class;
}
}

View File

@ -108,6 +108,7 @@ org.keycloak.logging.MappedDiagnosticContextSpi
org.keycloak.services.KeycloakServicesSpi
org.keycloak.services.client.ClientServiceSpi
org.keycloak.models.mapper.ModelMapperSpi
org.keycloak.validation.jakarta.JakartaValidatorSpi
org.keycloak.models.policy.ResourceActionSpi
org.keycloak.models.policy.ResourcePolicySpi
org.keycloak.models.policy.ResourcePolicyConditionSpi

View File

@ -10,22 +10,22 @@ import org.keycloak.services.ServiceException;
public interface ClientService extends Service {
public static class ClientSearchOptions {
class ClientSearchOptions {
// TODO
}
public static class ClientProjectionOptions {
class ClientProjectionOptions {
// TODO
}
public static class ClientSortAndSliceOptions {
class ClientSortAndSliceOptions {
// order by
// offset
// limit
// NOTE: this is not always the most desirable way to do pagination
}
public record CreateOrUpdateResult(ClientRepresentation representation, boolean created) {}
record CreateOrUpdateResult(ClientRepresentation representation, boolean created) {}
Optional<ClientRepresentation> getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions);

View File

@ -86,6 +86,16 @@
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator-cdi</artifactId>
<version>${hibernate-validator.version}</version> <!--Not sure why we need to set it as it should be part of dependencyManagement-->
</dependency>
<dependency>
<groupId>org.glassfish.expressly</groupId>
<artifactId>expressly</artifactId>
<version>${expressly.version}</version>
</dependency>
<dependency>
<groupId>org.jboss.logging</groupId>
<artifactId>jboss-logging</artifactId>

View File

@ -1,14 +1,23 @@
package org.keycloak.services.client;
import jakarta.enterprise.inject.spi.CDI;
import jakarta.inject.Inject;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import jakarta.ws.rs.core.Response;
import org.hibernate.validator.HibernateValidator;
import org.hibernate.validator.HibernateValidatorFactory;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.mapper.ClientModelMapper;
import org.keycloak.models.mapper.ModelMapper;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.representations.admin.v2.validation.CreateClient;
import org.keycloak.services.ServiceException;
import org.keycloak.validation.jakarta.HibernateValidatorProvider;
import org.keycloak.validation.jakarta.JakartaValidatorProvider;
import java.util.Optional;
import java.util.stream.Stream;
@ -17,10 +26,12 @@ import java.util.stream.Stream;
public class DefaultClientService implements ClientService {
private final KeycloakSession session;
private final ClientModelMapper mapper;
private final Validator validator;
public DefaultClientService(KeycloakSession session) {
this.session = session;
this.mapper = session.getProvider(ModelMapper.class).clients();
this.validator = session.getProvider(JakartaValidatorProvider.class).getValidator();
}
@Override
@ -45,6 +56,7 @@ public class DefaultClientService implements ClientService {
throw new ServiceException("Client already exists", Response.Status.CONFLICT);
}
} else {
validator.validate(client, CreateClient.class); // TODO improve it to avoid second validation when we know it is create and not update
model = realm.addClient(client.getClientId());
created = true;
}

View File

@ -4,6 +4,7 @@ import static org.keycloak.services.resources.KeycloakApplication.getSessionFact
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonProcessingException;
import jakarta.validation.ValidationException;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.OAuthErrorException;
@ -100,6 +101,8 @@ public class KeycloakErrorHandler implements ExceptionMapper<Throwable> {
error.setErrorDescription("Cannot parse the JSON");
} else if (isServerError) {
error.setErrorDescription("For more on this error consult the server log.");
} else if (throwable instanceof ValidationException) {
error.setErrorDescription(throwable.getMessage());
}
return Response.status(responseStatus)

View File

@ -0,0 +1,21 @@
package org.keycloak.validation.jakarta;
import jakarta.validation.Validator;
public class HibernateValidatorProvider implements JakartaValidatorProvider {
private final Validator validator;
public HibernateValidatorProvider(Validator validator) {
this.validator = validator;
}
@Override
public Validator getValidator() {
return validator;
}
@Override
public void close() {
}
}

View File

@ -0,0 +1,40 @@
package org.keycloak.validation.jakarta;
import jakarta.enterprise.inject.spi.CDI;
import jakarta.validation.Validator;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class HibernateValidatorProviderFactory implements JakartaValidatorProviderFactory {
public static final String PROVIDER_ID = "default";
private static HibernateValidatorProvider SINGLETON;
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public JakartaValidatorProvider create(KeycloakSession session) {
if (SINGLETON == null) {
SINGLETON = new HibernateValidatorProvider(CDI.current().select(Validator.class).get());
}
return SINGLETON;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@ -0,0 +1 @@
org.keycloak.validation.jakarta.HibernateValidatorProviderFactory

View File

@ -17,14 +17,17 @@
package org.keycloak.tests.admin;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPatch;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.util.EntityUtils;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.keycloak.admin.api.client.ClientApi;
@ -32,31 +35,38 @@ import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.testframework.annotations.InjectHttpClient;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
@KeycloakIntegrationTest()
public class AdminV2Test {
private static final String HOSTNAME_LOCAL_ADMIN = "http://localhost:8080/admin/api/v2";
private static ObjectMapper mapper;
@InjectHttpClient
private HttpClient client;
@BeforeAll
public static void setupMapper() {
mapper = new ObjectMapper();
}
@Test
public void testGetClient() throws Exception {
public void getClient() throws Exception {
HttpGet request = new HttpGet(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/account");
HttpResponse response = client.execute(request);
assertEquals(200, response.getStatusLine().getStatusCode());
ObjectMapper mapper = new ObjectMapper();
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertEquals("account", client.getClientId());
}
@Test
public void testJsonPatchClient() throws Exception {
public void jsonPatchClient() throws Exception {
HttpPatch request = new HttpPatch(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/account");
request.setEntity(new StringEntity("not json"));
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_PATCH_JSON);
@ -72,22 +82,19 @@ public class AdminV2Test {
response = client.execute(request);
assertEquals(200, response.getStatusLine().getStatusCode());
ObjectMapper mapper = new ObjectMapper();
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertEquals("I'm a description", client.getDescription());
}
@Disabled
@Test
public void testJsonMergePatchClient() throws Exception {
public void jsonMergePatchClient() throws Exception {
HttpPatch request = new HttpPatch(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/account");
request.setHeader(HttpHeaders.CONTENT_TYPE, ClientApi.CONENT_TYPE_MERGE_PATCH);
ClientRepresentation patch = new ClientRepresentation();
patch.setDescription("I'm also a description");
ObjectMapper mapper = new ObjectMapper();
request.setEntity(new StringEntity(mapper.writeValueAsString(patch)));
HttpResponse response = client.execute(request);
@ -97,4 +104,22 @@ public class AdminV2Test {
assertEquals("I'm also a description", client.getDescription());
}
@Test
public void clientRepresentationValidation() throws Exception {
HttpPost request = new HttpPost(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients");
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
request.setEntity(new StringEntity("""
{
"displayName": "something",
"appUrl": "notUrl"
}
"""));
var response = client.execute(request);
assertThat(response, notNullValue());
System.err.println(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8));
assertThat(response.getStatusLine().getStatusCode(), is(400));
}
}