task: use client v1 logic for v2 impl (#43982)

* task: use client v1 logic for v2 impl

closes: #43733

Signed-off-by: Steve Hawkins <shawkins@redhat.com>

* removing the provider module

Signed-off-by: Steve Hawkins <shawkins@redhat.com>

---------

Signed-off-by: Steve Hawkins <shawkins@redhat.com>
This commit is contained in:
Steven Hawkins 2025-11-12 09:08:27 -05:00 committed by GitHub
parent c0be5c42b9
commit 63fc0eec28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 282 additions and 906 deletions

View File

@ -1145,11 +1145,6 @@
<artifactId>keycloak-admin-v2-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-v2-providers</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-v2-rest</artifactId>

View File

@ -424,17 +424,6 @@
</exclusions>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-v2-providers</artifactId>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-v2-rest</artifactId>

View File

@ -20,6 +20,11 @@
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
@ -40,5 +45,34 @@
<groupId>jakarta.ws.rs</groupId>
<artifactId>jakarta.ws.rs-api</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
<dependency>
<groupId>jakarta.enterprise</groupId>
<artifactId>jakarta.enterprise.cdi-api</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgument>
-AgeneratedTranslationFilesPath=${project.build.directory}/generated-translation-files
</compilerArgument>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -1,11 +1,7 @@
package org.keycloak.models.mapper;
import org.keycloak.provider.Provider;
public interface ModelMapper extends Provider {
public interface ModelMapper {
ClientModelMapper clients();
default void close() {
}
}

View File

@ -1,6 +0,0 @@
package org.keycloak.models.mapper;
import org.keycloak.provider.ProviderFactory;
public interface ModelMapperFactory extends ProviderFactory<ModelMapper> {
}

View File

@ -1,35 +0,0 @@
package org.keycloak.models.mapper;
import org.keycloak.common.Profile;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class ModelMapperSpi implements Spi {
public static final String NAME = "model-mapper";
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends ModelMapper> getProviderClass() {
return ModelMapper.class;
}
@Override
public Class<? extends ProviderFactory<ModelMapper>> getProviderFactoryClass() {
return ModelMapperFactory.class;
}
@Override
public boolean isInternal() {
return true;
}
@Override
public boolean isEnabled() {
// Currently used only by Client Admin API v2
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2);
}
}

View File

@ -5,7 +5,6 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
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;
@ -167,7 +166,6 @@ 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;
@ -216,7 +214,6 @@ 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

@ -1,9 +1,7 @@
package org.keycloak.services;
import org.keycloak.provider.Provider;
/**
* Service handling business logic for various user interfaces (REST API, GraphQL, GitOps,...)
*/
public interface Service extends Provider {
public interface Service {
}

View File

@ -7,6 +7,8 @@ import org.keycloak.models.RealmModel;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.services.Service;
import org.keycloak.services.ServiceException;
import org.keycloak.services.resources.admin.ClientResource;
import org.keycloak.services.resources.admin.ClientsResource;
public interface ClientService extends Service {
@ -27,14 +29,14 @@ public interface ClientService extends Service {
record CreateOrUpdateResult(ClientRepresentation representation, boolean created) {}
Optional<ClientRepresentation> getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions);
Optional<ClientRepresentation> getClient(ClientResource clientResource, RealmModel realm, String clientId, ClientProjectionOptions projectionOptions);
Stream<ClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions, ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions);
Stream<ClientRepresentation> getClients(ClientsResource clientsResource, RealmModel realm, ClientProjectionOptions projectionOptions, ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions);
ClientRepresentation deleteClient(RealmModel realm, String clientId);
Stream<ClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions);
CreateOrUpdateResult createOrUpdate(RealmModel realm, ClientRepresentation client, boolean allowUpdate) throws ServiceException;
CreateOrUpdateResult createOrUpdate(ClientsResource clientsResource, ClientResource clientResource, RealmModel realm, ClientRepresentation client, boolean allowUpdate) throws ServiceException;
}

View File

@ -1,6 +0,0 @@
package org.keycloak.services.client;
import org.keycloak.provider.ProviderFactory;
public interface ClientServiceFactory extends ProviderFactory<ClientService> {
}

View File

@ -1,34 +0,0 @@
package org.keycloak.services.client;
import org.keycloak.common.Profile;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class ClientServiceSpi implements Spi {
public static final String NAME = "client-service";
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends ClientService> getProviderClass() {
return ClientService.class;
}
@Override
public Class<? extends ProviderFactory<ClientService>> getProviderFactoryClass() {
return ClientServiceFactory.class;
}
@Override
public boolean isInternal() {
return true;
}
@Override
public boolean isEnabled() {
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2);
}
}

View File

@ -0,0 +1,91 @@
package org.keycloak.services.client;
import jakarta.ws.rs.core.Response;
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.MapStructModelMapper;
import org.keycloak.models.utils.ModelToRepresentation;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.representations.admin.v2.validation.CreateClientDefault;
import org.keycloak.services.ServiceException;
import org.keycloak.services.resources.admin.ClientResource;
import org.keycloak.services.resources.admin.ClientsResource;
import org.keycloak.validation.jakarta.HibernateValidatorProvider;
import org.keycloak.validation.jakarta.JakartaValidatorProvider;
import java.util.Optional;
import java.util.stream.Stream;
// TODO
public class DefaultClientService implements ClientService {
private final KeycloakSession session;
private final ClientModelMapper mapper;
private final JakartaValidatorProvider validator;
public DefaultClientService(KeycloakSession session) {
this.session = session;
this.mapper = new MapStructModelMapper().clients();
this.validator = new HibernateValidatorProvider();
}
@Override
public Optional<ClientRepresentation> getClient(ClientResource clientResource, RealmModel realm, String clientId,
ClientProjectionOptions projectionOptions) {
// TODO: is the access map on the representation needed
return Optional.ofNullable(clientResource).map(ClientResource::viewClientModel).map(mapper::fromModel);
}
@Override
public Stream<ClientRepresentation> getClients(ClientsResource clientsResource, RealmModel realm,
ClientProjectionOptions projectionOptions, ClientSearchOptions searchOptions,
ClientSortAndSliceOptions sortAndSliceOptions) {
// TODO: is the access map on the representation needed
return clientsResource.getClientModels(null, true, false, null, null, null).map(mapper::fromModel);
}
@Override
public CreateOrUpdateResult createOrUpdate(ClientsResource clientsResource, ClientResource clientResource,
RealmModel realm, ClientRepresentation client, boolean allowUpdate) throws ServiceException {
boolean created = false;
ClientModel model = null;
if (clientResource != null) {
if (!allowUpdate) {
throw new ServiceException("Client already exists", Response.Status.CONFLICT);
}
model = clientResource.viewClientModel();
mapper.toModel(model, client, realm);
var rep = ModelToRepresentation.toRepresentation(model, session);
clientResource.update(rep);
} else {
created = true;
validator.validate(client, CreateClientDefault.class); // TODO improve it to avoid second validation when we know it is create and not update
// dummy add/remove to obtain a detached model
model = realm.addClient(client.getClientId());
realm.removeClient(model.getId());
mapper.toModel(model, client, realm);
var rep = ModelToRepresentation.toRepresentation(model, session);
model = clientsResource.createClientModel(rep);
}
var updated = mapper.fromModel(model);
return new CreateOrUpdateResult(updated, created);
}
@Override
public ClientRepresentation deleteClient(RealmModel realm, String clientId) {
// TODO Auto-generated method stub
return null;
}
@Override
public Stream<ClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions) {
// TODO Auto-generated method stub
return null;
}
}

View File

@ -1,5 +1,6 @@
package org.keycloak.validation.jakarta;
import jakarta.enterprise.inject.spi.CDI;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.Validator;
@ -8,10 +9,10 @@ import java.util.Set;
import java.util.function.Function;
public class HibernateValidatorProvider implements JakartaValidatorProvider {
private final Validator validator;
public HibernateValidatorProvider(Validator validator) {
this.validator = validator;
private final Validator validator = CDI.current().select(Validator.class).get();
public HibernateValidatorProvider() {
}
@Override
@ -35,8 +36,4 @@ public class HibernateValidatorProvider implements JakartaValidatorProvider {
return validator;
}
@Override
public void close() {
}
}

View File

@ -3,12 +3,11 @@ package org.keycloak.validation.jakarta;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.Validator;
import org.keycloak.provider.Provider;
import java.util.Set;
import java.util.function.Function;
public interface JakartaValidatorProvider extends Provider {
public interface JakartaValidatorProvider {
<T> void validate(T object, Class<?>... groups) throws ConstraintViolationException;

View File

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

View File

@ -1,34 +0,0 @@
package org.keycloak.validation.jakarta;
import org.keycloak.common.Profile;
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;
}
@Override
public boolean isEnabled() {
// Currently used only by Client Admin API v2
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2);
}
}

View File

@ -1,3 +0,0 @@
org.keycloak.services.client.ClientServiceSpi
org.keycloak.models.mapper.ModelMapperSpi
org.keycloak.validation.jakarta.JakartaValidatorSpi

View File

@ -16,7 +16,6 @@
<modules>
<module>rest</module>
<module>api</module>
<module>providers</module>
<module>tests</module>
</modules>
</project>

View File

@ -1,61 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-v2-parent</artifactId>
<version>999.0.0-SNAPSHOT</version>
</parent>
<artifactId>keycloak-admin-v2-providers</artifactId>
<name>Keycloak Admin API v2 Providers</name>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-v2-api</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
</dependency>
<dependency>
<groupId>jakarta.enterprise</groupId>
<artifactId>jakarta.enterprise.cdi-api</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgument>
-AgeneratedTranslationFilesPath=${project.build.directory}/generated-translation-files
</compilerArgument>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -1,38 +0,0 @@
package org.keycloak.models.mapper;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class MapStructModelMapperFactory implements ModelMapperFactory {
public static final String PROVIDER_ID = "default";
private static ModelMapper SINGLETON;
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public ModelMapper create(KeycloakSession session) {
if (SINGLETON == null) {
SINGLETON = new MapStructModelMapper();
}
return SINGLETON;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@ -1,83 +0,0 @@
package org.keycloak.services.client;
import jakarta.ws.rs.core.Response;
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.CreateClientDefault;
import org.keycloak.services.ServiceException;
import org.keycloak.validation.jakarta.JakartaValidatorProvider;
import java.util.Optional;
import java.util.stream.Stream;
// TODO
public class DefaultClientService implements ClientService {
private final KeycloakSession session;
private final ClientModelMapper mapper;
private final JakartaValidatorProvider validator;
public DefaultClientService(KeycloakSession session) {
this.session = session;
this.mapper = session.getProvider(ModelMapper.class).clients();
this.validator = session.getProvider(JakartaValidatorProvider.class);
}
@Override
public Optional<ClientRepresentation> getClient(RealmModel realm, String clientId,
ClientProjectionOptions projectionOptions) {
return Optional.ofNullable(realm.getClientByClientId(clientId)).map(mapper::fromModel);
}
@Override
public Stream<ClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions,
ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions) {
return realm.getClientsStream().map(mapper::fromModel);
}
@Override
public CreateOrUpdateResult createOrUpdate(RealmModel realm, ClientRepresentation client, boolean allowUpdate)
throws ServiceException {
boolean created = false;
ClientModel model = realm.getClientByClientId(client.getClientId());
if (model != null) {
if (!allowUpdate) {
throw new ServiceException("Client already exists", Response.Status.CONFLICT);
}
} else {
validator.validate(client, CreateClientDefault.class); // TODO improve it to avoid second validation when we know it is create and not update
model = realm.addClient(client.getClientId());
created = true;
}
// TODO: defaulting, validation, canonicalization
mapper.toModel(model, client, realm);
var updated = mapper.fromModel(model);
return new CreateOrUpdateResult(updated, created);
}
@Override
public ClientRepresentation deleteClient(RealmModel realm, String clientId) {
// TODO Auto-generated method stub
return null;
}
@Override
public Stream<ClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions) {
// TODO Auto-generated method stub
return null;
}
@Override
public void close() {
}
}

View File

@ -1,34 +0,0 @@
package org.keycloak.services.client;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class DefaultClientServiceFactory implements ClientServiceFactory {
public static final String PROVIDER_ID = "default";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public ClientService create(KeycloakSession session) {
return new DefaultClientService(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@ -1,40 +0,0 @@
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

@ -1 +0,0 @@
org.keycloak.models.mapper.MapStructModelMapperFactory

View File

@ -1 +0,0 @@
org.keycloak.services.client.DefaultClientServiceFactory

View File

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

View File

@ -2,9 +2,8 @@ package org.keycloak.admin.api;
import jakarta.ws.rs.Path;
import org.keycloak.admin.api.realm.RealmsApi;
import org.keycloak.provider.Provider;
public interface AdminApi extends Provider {
public interface AdminApi {
@Path("realms")
RealmsApi realms();

View File

@ -1,6 +0,0 @@
package org.keycloak.admin.api;
import org.keycloak.provider.ProviderFactory;
public interface AdminApiFactory extends ProviderFactory<AdminApi> {
}

View File

@ -1,35 +0,0 @@
package org.keycloak.admin.api;
import org.keycloak.common.Profile;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class AdminApiSpi implements Spi {
public static final String PROVIDER_ID = "admin-api-root";
@Override
public String getName() {
return PROVIDER_ID;
}
@Override
public Class<? extends Provider> getProviderClass() {
return AdminApi.class;
}
@Override
public Class<? extends ProviderFactory<AdminApi>> getProviderFactoryClass() {
return AdminApiFactory.class;
}
@Override
public boolean isInternal() {
return true;
}
@Override
public boolean isEnabled() {
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2); // There's currently only Client API for the new Admin API v2
}
}

View File

@ -17,17 +17,10 @@ public class AdminRootV2 {
@Context
protected KeycloakSession session;
@Path("")
public AdminApi latestAdminApi() {
checkApiEnabled();
// we could return the latest Admin API if no version is specified
return session.getProvider(AdminApi.class);
}
@Path("v2")
public AdminApi adminApi() {
checkApiEnabled();
return session.getProvider(AdminApi.class);
return new DefaultAdminApi(session);
}
@Path("{any:.*}")

View File

@ -7,11 +7,14 @@ import org.keycloak.admin.api.realm.DefaultRealmsApi;
import org.keycloak.admin.api.realm.RealmsApi;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.services.resources.admin.AdminAuth;
import org.keycloak.services.resources.admin.AdminRoot;
import org.keycloak.services.resources.admin.RealmsAdminResource;
public class DefaultAdminApi implements AdminApi {
private final KeycloakSession session;
private final RealmsAdminResource realmsAdminResource;
private final AdminAuth auth;
public DefaultAdminApi(KeycloakSession session) {
@ -22,16 +25,13 @@ public class DefaultAdminApi implements AdminApi {
if (!auth.getRealm().getName().equals(Config.getAdminRealm()) || !auth.hasRealmRole(AdminRoles.ADMIN)) {
throw new NotAuthorizedException("Wrong permissions");
}
this.realmsAdminResource = new RealmsAdminResource(session, auth, new TokenManager());
}
@Path("realms")
@Override
public RealmsApi realms() {
return new DefaultRealmsApi(session);
return new DefaultRealmsApi(session, realmsAdminResource);
}
@Override
public void close() {
}
}

View File

@ -1,34 +0,0 @@
package org.keycloak.admin.api;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class DefaultAdminApiFactory implements AdminApiFactory {
public static final String PROVIDER_ID = "default";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public AdminApi create(KeycloakSession session) {
return new DefaultAdminApi(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@ -10,12 +10,11 @@ import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import org.keycloak.admin.api.FieldValidation;
import org.keycloak.provider.Provider;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import com.fasterxml.jackson.databind.JsonNode;
public interface ClientApi extends Provider {
public interface ClientApi {
// TODO move these
String CONTENT_TYPE_MERGE_PATCH = "application/merge-patch+json";

View File

@ -1,6 +0,0 @@
package org.keycloak.admin.api.client;
import org.keycloak.provider.ProviderFactory;
public interface ClientApiFactory extends ProviderFactory<ClientApi> {
}

View File

@ -1,35 +0,0 @@
package org.keycloak.admin.api.client;
import org.keycloak.common.Profile;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class ClientApiSpi implements Spi {
public static final String NAME = "admin-api-client";
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends Provider> getProviderClass() {
return ClientApi.class;
}
@Override
public Class<? extends ProviderFactory<ClientApi>> getProviderFactoryClass() {
return ClientApiFactory.class;
}
@Override
public boolean isInternal() {
return true;
}
@Override
public boolean isEnabled() {
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2);
}
}

View File

@ -8,7 +8,6 @@ import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.keycloak.admin.api.FieldValidation;
import org.keycloak.provider.Provider;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import jakarta.ws.rs.Consumes;
@ -22,7 +21,7 @@ import org.keycloak.services.resources.KeycloakOpenAPI;
@Tag(name = KeycloakOpenAPI.Admin.Tags.CLIENTS_V2)
@Extension(name = KeycloakOpenAPI.Profiles.ADMIN, value = "")
public interface ClientsApi extends Provider {
public interface ClientsApi {
@GET
@Produces(MediaType.APPLICATION_JSON)

View File

@ -1,6 +0,0 @@
package org.keycloak.admin.api.client;
import org.keycloak.provider.ProviderFactory;
public interface ClientsApiFactory extends ProviderFactory<ClientsApi> {
}

View File

@ -1,35 +0,0 @@
package org.keycloak.admin.api.client;
import org.keycloak.common.Profile;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class ClientsApiSpi implements Spi {
public static final String NAME = "admin-api-clients";
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends Provider> getProviderClass() {
return ClientsApi.class;
}
@Override
public Class<? extends ProviderFactory<ClientsApi>> getProviderFactoryClass() {
return ClientsApiFactory.class;
}
@Override
public boolean isInternal() {
return true;
}
@Override
public boolean isEnabled() {
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2);
}
}

View File

@ -12,6 +12,9 @@ import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.ServiceException;
import org.keycloak.services.client.ClientService;
import org.keycloak.services.client.DefaultClientService;
import org.keycloak.services.resources.admin.ClientResource;
import org.keycloak.services.resources.admin.ClientsResource;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
@ -33,24 +36,35 @@ public class DefaultClientApi implements ClientApi {
private final ClientService clientService;
private HttpResponse response;
public DefaultClientApi(KeycloakSession session) {
private final ClientResource clientResource;
private final ClientsResource clientsResource;
private final String clientId;
public DefaultClientApi(KeycloakSession session, ClientsResource clientsResource, ClientResource clientResource, String clientId) {
this.session = session;
this.realm = Objects.requireNonNull(session.getContext().getRealm());
this.client = Objects.requireNonNull(session.getContext().getClient());
this.clientService = session.getProvider(ClientService.class);
this.clientService = new DefaultClientService(session);
this.response = session.getContext().getHttpResponse();
this.clientsResource = clientsResource;
this.clientResource = clientResource;
this.clientId = clientId;
}
@Override
public ClientRepresentation getClient() {
return clientService.getClient(realm, client.getClientId(), null)
return clientService.getClient(clientResource, realm, client.getClientId(), null)
.orElseThrow(() -> new NotFoundException("Cannot find the specified client"));
}
@Override
public ClientRepresentation createOrUpdateClient(ClientRepresentation client, FieldValidation fieldValidation) {
try {
var result = clientService.createOrUpdate(realm, client, true);
if (!Objects.equals(clientId, client.getClientId())) {
throw new WebApplicationException("cliendId in payload does not match the clientId in the path", Response.Status.BAD_REQUEST);
}
validateUnknownFields(fieldValidation, client, response);
var result = clientService.createOrUpdate(clientsResource, clientResource, realm, client, true);
if (result.created()) {
response.setStatus(Response.Status.CREATED.getStatusCode());
}
@ -79,16 +93,8 @@ public class DefaultClientApi implements ClientApi {
updated = objectReader.readValue(patch);
}
// TODO: reuse in the other methods
if (!updated.getAdditionalFields().isEmpty()) {
if (fieldValidation == null || fieldValidation == FieldValidation.Strict) {
// validation failed
throw new WebApplicationException("Payload contains unknown fields: " + updated.getAdditionalFields().keySet(), Response.Status.BAD_REQUEST);
} else if (fieldValidation == FieldValidation.Warn) {
response.addHeader("WARNING", "Payload contains unknown fields: " + updated.getAdditionalFields().keySet());
}
}
return clientService.createOrUpdate(realm, updated, true).representation();
validateUnknownFields(fieldValidation, updated, response);
return clientService.createOrUpdate(clientsResource, clientResource, realm, updated, true).representation();
} catch (JsonPatchException e) {
// TODO: kubernetes uses 422 instead
throw new WebApplicationException(e.getMessage(), Response.Status.BAD_REQUEST);
@ -99,8 +105,15 @@ public class DefaultClientApi implements ClientApi {
}
}
@Override
public void close() {
static void validateUnknownFields(FieldValidation fieldValidation, ClientRepresentation rep, HttpResponse response) {
if (!rep.getAdditionalFields().isEmpty()) {
if (fieldValidation == null || fieldValidation == FieldValidation.Strict) {
// validation failed
throw new WebApplicationException("Payload contains unknown fields: " + rep.getAdditionalFields().keySet(), Response.Status.BAD_REQUEST);
} else if (fieldValidation == FieldValidation.Warn) {
response.addHeader("WARNING", "Payload contains unknown fields: " + rep.getAdditionalFields().keySet());
}
}
}
}

View File

@ -1,34 +0,0 @@
package org.keycloak.admin.api.client;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class DefaultClientApiFactory implements ClientApiFactory {
public static final String PROVIDER_ID = "default";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public ClientApi create(KeycloakSession session) {
return new DefaultClientApi(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@ -5,7 +5,6 @@ import java.util.Optional;
import java.util.stream.Stream;
import jakarta.validation.Valid;
import jakarta.ws.rs.NotFoundException;
import org.keycloak.admin.api.FieldValidation;
import org.keycloak.http.HttpResponse;
import org.keycloak.models.KeycloakSession;
@ -14,10 +13,14 @@ import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.representations.admin.v2.validation.CreateClientDefault;
import org.keycloak.services.ServiceException;
import org.keycloak.services.client.ClientService;
import org.keycloak.services.client.DefaultClientService;
import org.keycloak.services.resources.admin.ClientsResource;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import org.keycloak.validation.jakarta.HibernateValidatorProvider;
import org.keycloak.validation.jakarta.JakartaValidatorProvider;
public class DefaultClientsApi implements ClientsApi {
@ -26,26 +29,29 @@ public class DefaultClientsApi implements ClientsApi {
private final HttpResponse response;
private final ClientService clientService;
private final JakartaValidatorProvider validator;
private final ClientsResource clientsResource;
public DefaultClientsApi(KeycloakSession session) {
public DefaultClientsApi(KeycloakSession session, ClientsResource clientsResource) {
this.session = session;
this.realm = Objects.requireNonNull(session.getContext().getRealm());
this.clientService = session.getProvider(ClientService.class);
this.clientService = new DefaultClientService(session);
this.response = session.getContext().getHttpResponse();
this.validator = session.getProvider(JakartaValidatorProvider.class);
this.validator = new HibernateValidatorProvider();
this.clientsResource = clientsResource;
}
@Override
public Stream<ClientRepresentation> getClients() {
return clientService.getClients(realm, null, null, null);
return clientService.getClients(clientsResource, realm, null, null, null);
}
@Override
public ClientRepresentation createClient(@Valid ClientRepresentation client, FieldValidation fieldValidation) {
try {
DefaultClientApi.validateUnknownFields(fieldValidation, client, response);
validator.validate(client, CreateClientDefault.class);
response.setStatus(Response.Status.CREATED.getStatusCode());
return clientService.createOrUpdate(realm, client, false).representation();
return clientService.createOrUpdate(clientsResource, null, realm, client, false).representation();
} catch (ServiceException e) {
throw new WebApplicationException(e.getMessage(), e.getSuggestedResponseStatus().orElse(Response.Status.BAD_REQUEST));
}
@ -53,13 +59,8 @@ public class DefaultClientsApi implements ClientsApi {
@Override
public ClientApi client(@PathParam("id") String clientId) {
var client = Optional.ofNullable(session.clients().getClientByClientId(realm, clientId)).orElseThrow(() -> new NotFoundException("Client cannot be found"));
session.getContext().setClient(client);
return session.getProvider(ClientApi.class);
var client = Optional.ofNullable(session.clients().getClientByClientId(realm, clientId));
return new DefaultClientApi(session, clientsResource, client.map(c -> clientsResource.getClient(c.getId())).orElse(null), clientId);
}
@Override
public void close() {
}
}

View File

@ -1,34 +0,0 @@
package org.keycloak.admin.api.client;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class DefaultClientsApiFactory implements ClientsApiFactory {
public static final String PROVIDER_ID = "default";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public ClientsApi create(KeycloakSession session) {
return new DefaultClientsApi(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@ -2,26 +2,23 @@ package org.keycloak.admin.api.realm;
import jakarta.ws.rs.Path;
import org.keycloak.admin.api.client.ClientsApi;
import org.keycloak.admin.api.client.DefaultClientsApi;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import java.util.Objects;
import org.keycloak.services.resources.admin.RealmAdminResource;
public class DefaultRealmApi implements RealmApi {
private final KeycloakSession session;
private final RealmModel realm;
private final RealmAdminResource realmAdminResource;
public DefaultRealmApi(KeycloakSession session) {
public DefaultRealmApi(KeycloakSession session, RealmAdminResource realmAdmin) {
this.session = session;
this.realm = Objects.requireNonNull(session.getContext().getRealm());
this.realmAdminResource = realmAdmin;
}
@Path("clients")
@Override
public ClientsApi clients() {
return session.getProvider(ClientsApi.class);
return new DefaultClientsApi(session, realmAdminResource.getClients());
}
@Override
public void close() {}
}

View File

@ -1,34 +0,0 @@
package org.keycloak.admin.api.realm;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class DefaultRealmApiFactory implements RealmApiFactory {
public static final String PROVIDER_ID = "default";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public RealmApi create(KeycloakSession session) {
return new DefaultRealmApi(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@ -1,29 +1,24 @@
package org.keycloak.admin.api.realm;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import org.keycloak.models.KeycloakSession;
import java.util.Optional;
import org.keycloak.services.resources.admin.RealmsAdminResource;
public class DefaultRealmsApi implements RealmsApi {
private final KeycloakSession session;
private final RealmsAdminResource realmsAdminResource;
public DefaultRealmsApi(KeycloakSession session) {
public DefaultRealmsApi(KeycloakSession session, RealmsAdminResource realmsAdminResource) {
this.session = session;
this.realmsAdminResource = realmsAdminResource;
}
@Path("{name}")
@Override
public RealmApi realm(@PathParam("name") String name) {
var realm = Optional.ofNullable(session.realms().getRealmByName(name)).orElseThrow(() -> new NotFoundException("Realm cannot be found"));
session.getContext().setRealm(realm);
return session.getProvider(RealmApi.class);
var realmAdmin = realmsAdminResource.getRealmAdmin(name);
return new DefaultRealmApi(session, realmAdmin);
}
@Override
public void close() {
}
}

View File

@ -1,34 +0,0 @@
package org.keycloak.admin.api.realm;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class DefaultRealmsApiFactory implements RealmsApiFactory {
public static final String PROVIDER_ID = "default";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public RealmsApi create(KeycloakSession session) {
return new DefaultRealmsApi(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@ -2,9 +2,8 @@ package org.keycloak.admin.api.realm;
import jakarta.ws.rs.Path;
import org.keycloak.admin.api.client.ClientsApi;
import org.keycloak.provider.Provider;
public interface RealmApi extends Provider {
public interface RealmApi {
@Path("clients")
ClientsApi clients();

View File

@ -1,6 +0,0 @@
package org.keycloak.admin.api.realm;
import org.keycloak.provider.ProviderFactory;
public interface RealmApiFactory extends ProviderFactory<RealmApi> {
}

View File

@ -1,36 +0,0 @@
package org.keycloak.admin.api.realm;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
import static org.keycloak.admin.api.AdminRootV2.isAdminApiV2Enabled;
public class RealmApiSpi implements Spi {
public static final String NAME = "admin-api-realm";
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends Provider> getProviderClass() {
return RealmApi.class;
}
@Override
public Class<? extends ProviderFactory<RealmApi>> getProviderFactoryClass() {
return RealmApiFactory.class;
}
@Override
public boolean isInternal() {
return true;
}
@Override
public boolean isEnabled() {
return isAdminApiV2Enabled();
}
}

View File

@ -1,11 +1,9 @@
package org.keycloak.admin.api.realm;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import org.keycloak.provider.Provider;
public interface RealmsApi extends Provider {
public interface RealmsApi {
@Path("{name}")
RealmApi realm(@PathParam("name") String name);

View File

@ -1,6 +0,0 @@
package org.keycloak.admin.api.realm;
import org.keycloak.provider.ProviderFactory;
public interface RealmsApiFactory extends ProviderFactory<RealmsApi> {
}

View File

@ -1,36 +0,0 @@
package org.keycloak.admin.api.realm;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
import static org.keycloak.admin.api.AdminRootV2.isAdminApiV2Enabled;
public class RealmsApiSpi implements Spi {
public static final String NAME = "admin-api-realms";
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends Provider> getProviderClass() {
return RealmsApi.class;
}
@Override
public Class<? extends ProviderFactory<RealmsApi>> getProviderFactoryClass() {
return RealmsApiFactory.class;
}
@Override
public boolean isInternal() {
return true;
}
@Override
public boolean isEnabled() {
return isAdminApiV2Enabled();
}
}

View File

@ -1 +0,0 @@
org.keycloak.admin.api.DefaultAdminApiFactory

View File

@ -1 +0,0 @@
org.keycloak.admin.api.client.DefaultClientApiFactory

View File

@ -1 +0,0 @@
org.keycloak.admin.api.client.DefaultClientsApiFactory

View File

@ -1 +0,0 @@
org.keycloak.admin.api.realm.DefaultRealmApiFactory

View File

@ -1 +0,0 @@
org.keycloak.admin.api.realm.DefaultRealmsApiFactory

View File

@ -1,5 +0,0 @@
org.keycloak.admin.api.AdminApiSpi
org.keycloak.admin.api.realm.RealmsApiSpi
org.keycloak.admin.api.realm.RealmApiSpi
org.keycloak.admin.api.client.ClientsApiSpi
org.keycloak.admin.api.client.ClientApiSpi

View File

@ -24,6 +24,7 @@ import org.apache.http.HttpMessage;
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.client.methods.HttpPut;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.util.EntityUtils;
@ -126,6 +127,51 @@ public class ClientApiV2Test {
}
}
@Test
public void putFailsWithDifferentClientId() throws Exception {
HttpPut request = new HttpPut(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/account");
setAuthHeader(request);
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
ClientRepresentation rep = new ClientRepresentation();
rep.setClientId("other");
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(request)) {
assertEquals(400, response.getStatusLine().getStatusCode());
}
}
@Test
public void putCreateOrUpdates() throws Exception {
HttpPut request = new HttpPut(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/other");
setAuthHeader(request);
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
ClientRepresentation rep = new ClientRepresentation();
rep.setEnabled(true);
rep.setClientId("other");
rep.setDescription("I'm new");
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(request)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertEquals("I'm new", client.getDescription());
}
rep.setDescription("I'm updated");
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(request)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertEquals("I'm updated", client.getDescription());
}
}
@Test
public void clientRepresentationValidation() throws Exception {
HttpPost request = new HttpPost(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients");

View File

@ -194,6 +194,16 @@ public class ClientResource {
@Tag(name = KeycloakOpenAPI.Admin.Tags.CLIENTS)
@Operation( summary = "Get representation of the client")
public ClientRepresentation getClient() {
viewClientModel();
ClientRepresentation representation = ModelToRepresentation.toRepresentation(client, session);
representation.setAccess(auth.clients().getAccess(client));
return representation;
}
public ClientModel viewClientModel() {
try {
session.clientPolicy().triggerOnEvent(new AdminClientViewContext(client, auth.adminAuth()));
} catch (ClientPolicyException cpe) {
@ -201,12 +211,7 @@ public class ClientResource {
}
auth.clients().requireView(client);
ClientRepresentation representation = ModelToRepresentation.toRepresentation(client, session);
representation.setAccess(auth.clients().getAccess(client));
return representation;
return client;
}
/**

View File

@ -119,6 +119,20 @@ public class ClientsResource {
@QueryParam("q") String searchQuery,
@Parameter(description = "the first result") @QueryParam("first") Integer firstResult,
@Parameter(description = "the max results to return") @QueryParam("max") Integer maxResults) {
return ModelToRepresentation.filterValidRepresentations(
getClientModels(clientId, viewableOnly, search, searchQuery, firstResult, maxResults), c -> {
ClientRepresentation representation = ModelToRepresentation.toRepresentation(c, session);
representation.setAccess(auth.clients().getAccess(c));
return representation;
});
}
public Stream<ClientModel> getClientModels(String clientId,
boolean viewableOnly,
boolean search,
String searchQuery,
Integer firstResult,
Integer maxResults) {
auth.clients().requireList();
boolean canView = AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm) || auth.clients().canView();
@ -152,21 +166,7 @@ public class ClientsResource {
throw new ErrorResponseException(Errors.INVALID_REQUEST, e.getMessage(), Response.Status.BAD_REQUEST);
}
Stream<ClientRepresentation> s = ModelToRepresentation.filterValidRepresentations(clientModels,
c -> {
ClientRepresentation representation = null;
if (canView || auth.clients().canView(c)) {
representation = ModelToRepresentation.toRepresentation(c, session);
representation.setAccess(auth.clients().getAccess(c));
} else if (!viewableOnly && auth.clients().canView(c)) {
representation = new ClientRepresentation();
representation.setId(c.getId());
representation.setClientId(c.getClientId());
representation.setDescription(c.getDescription());
}
return representation;
});
Stream<ClientModel> s = clientModels.filter(m -> canView || auth.clients().canView(m));
if (!canView) {
s = paginatedStream(s, firstResult, maxResults);
@ -196,6 +196,11 @@ public class ClientsResource {
@APIResponse(responseCode = "409", description = "Conflict")
})
public Response createClient(final ClientRepresentation rep) {
var created = createClientModel(rep);
return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(created.getId()).build()).build();
}
public ClientModel createClientModel(final ClientRepresentation rep) {
auth.clients().requireManage();
try {
@ -232,7 +237,7 @@ public class ClientsResource {
session.getContext().setClient(clientModel);
session.clientPolicy().triggerOnEvent(new AdminClientRegisteredContext(clientModel, auth.adminAuth()));
return Response.created(session.getContext().getUri().getAbsolutePathBuilder().path(clientModel.getId()).build()).build();
return clientModel;
} catch (ModelDuplicateException e) {
throw ErrorResponse.exists("Client " + rep.getClientId() + " already exists");
} catch (ClientPolicyException cpe) {