[Admin API v2] Skeleton prototype (#39322)

* Add new ClientRepresentation

Co-authored-by: Peter Zaoral <pzaoral@redhat.com>
Co-authored-by: Václav Muzikář <vmuzikar@redhat.com>
Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Add APIs

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Add ApiModelMapper SPI

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Add MapStruct as default ApiModelMapper

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Add default APIs implementations

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Provide Service SPI and ClientService

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Add default Keycloak services and Client service

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Add ModelMapper to shared modules

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Implement Client service, add ServiceException class

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Use ClientService in Client REST API

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Update rest/admin-api/src/main/java/org/keycloak/admin/api/client/ClientsApi.java

Co-authored-by: Václav Muzikář <vaclav@muzikari.cz>
Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Fix ModelMapperSpi

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Use /admin/api/v2 as a root path

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Support latest API version by default

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

* Rename path param to comply with API spec

Signed-off-by: Martin Bartoš <mabartos@redhat.com>

---------

Signed-off-by: Martin Bartoš <mabartos@redhat.com>
Co-authored-by: Peter Zaoral <pzaoral@redhat.com>
Co-authored-by: Václav Muzikář <vmuzikar@redhat.com>
Co-authored-by: Václav Muzikář <vaclav@muzikari.cz>
This commit is contained in:
Martin Bartoš
2025-05-06 17:43:42 +02:00
parent cbf915c570
commit fff34d3bd5
45 changed files with 1080 additions and 3 deletions

View File

@@ -0,0 +1,225 @@
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 java.util.Set;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class ClientRepresentation {
@JsonProperty(required = true)
@JsonPropertyDescription("ID uniquely identifying this client")
private String clientId;
@JsonPropertyDescription("Human readable name of the client")
private String displayName;
@JsonPropertyDescription("Human readable description of the client")
private String description;
@JsonPropertyDescription("The protocol used to communicate with the client")
private String protocol;
@JsonPropertyDescription("Whether this client is enabled")
private Boolean enabled;
@JsonPropertyDescription("URL to the application's homepage that is represented by this client")
private String appUrl;
@JsonPropertyDescription("URLs that the browser can redirect to after login")
private Set<String> appRedirectUrls;
@JsonPropertyDescription("Login flows that are enabled for this client")
private Set<String> loginFlows;
@JsonPropertyDescription("Authentication configuration for this client")
private Auth auth;
@JsonPropertyDescription("Web origins that are allowed to make requests to this client")
private Set<String> webOrigins;
@JsonPropertyDescription("Roles associated with this client")
private Set<String> roles;
@JsonPropertyDescription("Service account configuration for this client")
private ServiceAccount serviceAccount;
public ClientRepresentation() {}
public ClientRepresentation(String clientId) {
this.clientId = clientId;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getProtocol() {
return protocol;
}
public void setProtocol(String protocol) {
this.protocol = protocol;
}
public Boolean getEnabled() {
return enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
public String getAppUrl() {
return appUrl;
}
public void setAppUrl(String appUrl) {
this.appUrl = appUrl;
}
public Set<String> getAppRedirectUrls() {
return appRedirectUrls;
}
public void setAppRedirectUrls(Set<String> appRedirectUrls) {
this.appRedirectUrls = appRedirectUrls;
}
public Set<String> getLoginFlows() {
return loginFlows;
}
public void setLoginFlows(Set<String> loginFlows) {
this.loginFlows = loginFlows;
}
public Auth getAuth() {
return auth;
}
public void setAuth(Auth auth) {
this.auth = auth;
}
public Set<String> getWebOrigins() {
return webOrigins;
}
public void setWebOrigins(Set<String> webOrigins) {
this.webOrigins = webOrigins;
}
public Set<String> getRoles() {
return roles;
}
public void setRoles(Set<String> roles) {
this.roles = roles;
}
public ServiceAccount getServiceAccount() {
return serviceAccount;
}
public void setServiceAccount(ServiceAccount serviceAccount) {
this.serviceAccount = serviceAccount;
}
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public static class Auth {
@JsonPropertyDescription("Whether authentication is enabled for this client")
private Boolean enabled;
@JsonPropertyDescription("Which authentication method is used for this client")
private String method;
@JsonPropertyDescription("Secret used to authenticate this client with Secret authentication")
private String secret;
@JsonPropertyDescription("Public key used to authenticate this client with Signed JWT authentication")
private String certificate;
public Boolean getEnabled() {
return enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
public String getMethod() {
return method;
}
public void setMethod(String method) {
this.method = method;
}
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
public String getCertificate() {
return certificate;
}
public void setCertificate(String certificate) {
this.certificate = certificate;
}
}
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public static class ServiceAccount {
@JsonPropertyDescription("Whether the service account is enabled")
private Boolean enabled;
@JsonPropertyDescription("Roles assigned to the service account")
private Set<String> roles;
public Boolean getEnabled() {
return enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
public Set<String> getRoles() {
return roles;
}
public void setRoles(Set<String> roles) {
this.roles = roles;
}
}
}

12
pom.xml
View File

@@ -219,6 +219,8 @@
<!-- Used to test SAML Galleon feature-pack layers discovery -->
<version.org.wildfly.glow>1.0.0.Alpha8</version.org.wildfly.glow>
<org.mapstruct.version>1.6.3</org.mapstruct.version>
<!-- Galleon -->
<galleon.fork.embedded>true</galleon.fork.embedded>
<galleon.log.time>true</galleon.log.time>
@@ -385,6 +387,11 @@
<artifactId>xsom</artifactId>
<version>${org.glassfish.jaxb.xsom.version}</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
@@ -1108,6 +1115,11 @@
<artifactId>keycloak-admin-ui</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-rest-admin-ui-ext</artifactId>

View File

@@ -135,6 +135,11 @@
<artifactId>rdf-urdna</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
<!-- SmallRye -->
<dependency>
<groupId>io.smallrye.config</groupId>
@@ -394,6 +399,17 @@
</exclusions>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-api</artifactId>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Keycloak Dependencies-->
<dependency>
<groupId>org.jboss.logging</groupId>

View File

@@ -59,6 +59,10 @@
<groupId>org.infinispan</groupId>
<artifactId>infinispan-server-testdriver-core</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-api</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>

40
rest/admin-api/pom.xml Normal file
View File

@@ -0,0 +1,40 @@
<?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-rest-parent</artifactId>
<version>999.0.0-SNAPSHOT</version>
</parent>
<artifactId>keycloak-admin-api</artifactId>
<name>Keycloak Admin REST API v2</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-core</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
</dependency>
<dependency>
<groupId>jakarta.ws.rs</groupId>
<artifactId>jakarta.ws.rs-api</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,11 @@
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 {
@Path("realms")
RealmsApi realms();
}

View File

@@ -0,0 +1,34 @@
package org.keycloak.admin.api;
import jakarta.ws.rs.OPTIONS;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Context;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.keycloak.models.KeycloakSession;
import org.keycloak.services.resources.admin.AdminCorsPreflightService;
@jakarta.ws.rs.ext.Provider
@Path("admin/api")
public class AdminRootV2 {
@Context
protected KeycloakSession session;
@Path("")
public AdminApi latestAdminApi() {
// we could return the latest Admin API if no version is specified
return new DefaultAdminApi(session);
}
@Path("v2")
public AdminApi adminApi() {
return new DefaultAdminApi(session);
}
@Path("{any:.*}")
@OPTIONS
@Operation(hidden = true)
public Object preFlight() {
return new AdminCorsPreflightService();
}
}

View File

@@ -0,0 +1,25 @@
package org.keycloak.admin.api;
import jakarta.ws.rs.Path;
import org.keycloak.admin.api.realm.DefaultRealmsApi;
import org.keycloak.admin.api.realm.RealmsApi;
import org.keycloak.models.KeycloakSession;
public class DefaultAdminApi implements AdminApi {
private final KeycloakSession session;
public DefaultAdminApi(KeycloakSession session) {
this.session = session;
}
@Path("realms")
@Override
public RealmsApi realms() {
return new DefaultRealmsApi(session);
}
@Override
public void close() {
}
}

View File

@@ -0,0 +1,14 @@
package org.keycloak.admin.api.client;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import org.keycloak.representations.admin.v2.ClientRepresentation;
public interface ClientApi {
@GET
@Produces(MediaType.APPLICATION_JSON)
ClientRepresentation getClient(@QueryParam("runtime") Boolean isRuntime);
}

View File

@@ -0,0 +1,36 @@
package org.keycloak.admin.api.client;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.keycloak.provider.Provider;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import java.util.stream.Stream;
public interface ClientsApi extends Provider {
@GET
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
Stream<ClientRepresentation> getClients();
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
ClientRepresentation createOrUpdateClient(ClientRepresentation client);
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
ClientRepresentation createClient(ClientRepresentation client);
@Path("{id}")
ClientApi client(@PathParam("id") String id);
}

View File

@@ -0,0 +1,33 @@
package org.keycloak.admin.api.client;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.services.client.ClientService;
public class DefaultClientApi implements ClientApi {
private final KeycloakSession session;
private final RealmModel realm;
private final String clientId;
private final ClientService clientService;
public DefaultClientApi(KeycloakSession session, String clientId) {
this.session = session;
this.clientId = clientId;
this.realm = session.getContext().getRealm();
this.clientService = session.services().clients();
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Override
public ClientRepresentation getClient(@QueryParam("runtime") Boolean isRuntime) {
return clientService.getClient(realm, clientId, isRuntime)
.orElseThrow(() -> new NotFoundException("Cannot find the specified client"));
}
}

View File

@@ -0,0 +1,77 @@
package org.keycloak.admin.api.client;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
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.services.ServiceException;
import org.keycloak.services.client.ClientService;
import java.util.stream.Stream;
public class DefaultClientsApi implements ClientsApi {
private final KeycloakSession session;
private final RealmModel realm;
private final HttpResponse response;
private final ClientService clientService;
public DefaultClientsApi(KeycloakSession session, RealmModel realm) {
this.session = session;
this.realm = realm;
this.clientService = session.services().clients();
this.response = session.getContext().getHttpResponse();
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Override
public Stream<ClientRepresentation> getClients() {
return clientService.getClients(realm);
}
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public ClientRepresentation createOrUpdateClient(ClientRepresentation client) {
try {
// TODO return 200, or 201 if did not exist
response.setStatus(Response.Status.OK.getStatusCode());
return clientService.createOrUpdateClient(realm, client);
} catch (ServiceException e) {
throw new WebApplicationException(e.getMessage(), e.getSuggestedResponseStatus().orElse(Response.Status.BAD_REQUEST));
}
}
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public ClientRepresentation createClient(ClientRepresentation client) {
try {
response.setStatus(Response.Status.CREATED.getStatusCode());
return clientService.createClient(realm, client);
} catch (ServiceException e) {
throw new WebApplicationException(e.getMessage(), e.getSuggestedResponseStatus().orElse(Response.Status.BAD_REQUEST));
}
}
@Path("{id}")
@Override
public ClientApi client(@PathParam("id") String clientId) {
return new DefaultClientApi(session, clientId);
}
@Override
public void close() {
}
}

View File

@@ -0,0 +1,27 @@
package org.keycloak.admin.api.realm;
import jakarta.ws.rs.NotFoundException;
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.Optional;
public class DefaultRealmApi implements RealmApi {
private final KeycloakSession session;
private final RealmModel realm;
public DefaultRealmApi(KeycloakSession session, String name) {
this.session = session;
this.realm = Optional.ofNullable(session.realms().getRealmByName(name)).orElseThrow(() -> new NotFoundException("Realm cannot be found"));
session.getContext().setRealm(realm);
}
@Path("clients")
@Override
public ClientsApi clients() {
return new DefaultClientsApi(session, realm);
}
}

View File

@@ -0,0 +1,24 @@
package org.keycloak.admin.api.realm;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import org.keycloak.models.KeycloakSession;
public class DefaultRealmsApi implements RealmsApi {
private final KeycloakSession session;
public DefaultRealmsApi(KeycloakSession session) {
this.session = session;
}
@Path("{name}")
@Override
public RealmApi realm(@PathParam("name") String name) {
return new DefaultRealmApi(session, name);
}
@Override
public void close() {
}
}

View File

@@ -0,0 +1,10 @@
package org.keycloak.admin.api.realm;
import jakarta.ws.rs.Path;
import org.keycloak.admin.api.client.ClientsApi;
public interface RealmApi {
@Path("clients")
ClientsApi clients();
}

View File

@@ -0,0 +1,12 @@
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 {
@Path("{name}")
RealmApi realm(@PathParam("name") String name);
}

View File

@@ -32,6 +32,7 @@
<packaging>pom</packaging>
<modules>
<module>admin-api</module>
<module>admin-ui-ext</module>
</modules>

View File

@@ -0,0 +1,11 @@
package org.keycloak.models.mapper;
import org.keycloak.models.ClientModel;
import org.keycloak.representations.admin.v2.ClientRepresentation;
public interface ClientModelMapper {
ClientRepresentation fromModel(ClientModel model);
// ClientModel toModel(ClientModel baseModel, ClientRepresentation representation);
}

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
package org.keycloak.models.mapper;
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;
}
}

View File

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

View File

@@ -0,0 +1,28 @@
package org.keycloak.services;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class KeycloakServicesSpi implements Spi {
public static final String NAME = "keycloak-services";
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends KeycloakServices> getProviderClass() {
return KeycloakServices.class;
}
@Override
public Class<? extends ProviderFactory<KeycloakServices>> getProviderFactoryClass() {
return KeycloakServicesFactory.class;
}
@Override
public boolean isInternal() {
return true;
}
}

View File

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

View File

@@ -0,0 +1,28 @@
package org.keycloak.services.client;
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;
}
}

View File

@@ -105,6 +105,9 @@ org.keycloak.cookie.CookieSpi
org.keycloak.organization.OrganizationSpi
org.keycloak.securityprofile.SecurityProfileSpi
org.keycloak.logging.MappedDiagnosticContextSpi
org.keycloak.services.KeycloakServicesSpi
org.keycloak.services.client.ClientServiceSpi
org.keycloak.models.mapper.ModelMapperSpi
org.keycloak.models.policy.ResourceActionSpi
org.keycloak.models.policy.ResourcePolicySpi
org.keycloak.models.policy.ResourcePolicyConditionSpi

View File

@@ -20,6 +20,7 @@ package org.keycloak.models;
import org.keycloak.component.ComponentModel;
import org.keycloak.provider.InvalidationHandler.InvalidableObjectType;
import org.keycloak.provider.Provider;
import org.keycloak.services.KeycloakServices;
import org.keycloak.services.clientpolicy.ClientPolicyManager;
import org.keycloak.sessions.AuthenticationSessionProvider;
import org.keycloak.vault.VaultTranscriber;
@@ -211,6 +212,8 @@ public interface KeycloakSession extends AutoCloseable {
*/
IdentityProviderStorageProvider identityProviders();
KeycloakServices services();
@Override
void close();

View File

@@ -0,0 +1,9 @@
package org.keycloak.services;
import org.keycloak.provider.Provider;
import org.keycloak.services.client.ClientService;
public interface KeycloakServices extends Provider {
ClientService clients();
}

View File

@@ -0,0 +1,9 @@
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 {
}

View File

@@ -0,0 +1,27 @@
package org.keycloak.services;
import jakarta.ws.rs.core.Response;
import java.util.Optional;
public class ServiceException extends RuntimeException {
private Response.Status suggestedHttpResponseStatus;
public ServiceException(String message) {
super(message);
}
public ServiceException(String message, Throwable throwable) {
super(message, throwable);
}
public ServiceException(String message, Response.Status suggestedStatus) {
this(message);
this.suggestedHttpResponseStatus = suggestedStatus;
}
public Optional<Response.Status> getSuggestedResponseStatus() {
return Optional.ofNullable(suggestedHttpResponseStatus);
}
}

View File

@@ -0,0 +1,22 @@
package org.keycloak.services.client;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.services.Service;
import org.keycloak.services.ServiceException;
import java.util.Optional;
import java.util.stream.Stream;
public interface ClientService extends Service {
Optional<ClientRepresentation> getClient(RealmModel realm, String clientId);
Optional<ClientRepresentation> getClient(RealmModel realm, String clientId, Boolean fullRepresentation);
Stream<ClientRepresentation> getClients(RealmModel realm);
ClientRepresentation createOrUpdateClient(RealmModel realm, ClientRepresentation client) throws ServiceException;
ClientRepresentation createClient(RealmModel realm, ClientRepresentation client) throws ServiceException;
}

View File

@@ -81,7 +81,11 @@
<groupId>org.twitter4j</groupId>
<artifactId>twitter4j-core</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.jboss.logging</groupId>
<artifactId>jboss-logging</artifactId>
@@ -290,6 +294,11 @@
<artifactId>jboss-logging-processor</artifactId>
<version>${jboss-logging-annotations.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>

View File

@@ -0,0 +1,46 @@
package org.keycloak.models.mapper;
import org.keycloak.models.ClientModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.mapstruct.BeanMapping;
import org.mapstruct.Context;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.Named;
import org.mapstruct.NullValuePropertyMappingStrategy;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Mapper
public interface MapStructClientModelMapper extends ClientModelMapper {
@Mapping(target = "displayName", source = "name")
@Mapping(target = "appUrl", source = "baseUrl")
@Mapping(target = "appRedirectUrls", source = "redirectUris")
@Mapping(target = "loginFlows", source = "authenticationFlowBindingOverrides", ignore = true)
@Mapping(target = "auth", ignore = true) // TODO
@Mapping(target = "roles", source = "rolesStream", qualifiedByName = "getRoleStrings")
@Mapping(target = "serviceAccount.enabled", source = "serviceAccountsEnabled")
@Mapping(target = "serviceAccount.roles", source = "rolesStream", qualifiedByName = "getServiceAccountRoles")
@Override
ClientRepresentation fromModel(ClientModel model);
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void toModel(@MappingTarget ClientModel model, ClientRepresentation rep, @Context RealmModel realm);
@Named("getRoleStrings")
default Set<String> getRoleStrings(Stream<RoleModel> stream) {
return stream.map(RoleModel::getName).collect(Collectors.toSet());
}
@Named("getServiceAccountRoles")
default Set<String> getServiceAccountRoles(Stream<RoleModel> stream) {
return stream.filter(f -> true) //TODO check roles for SA
.map(RoleModel::getName)
.collect(Collectors.toSet());
}
}

View File

@@ -0,0 +1,16 @@
package org.keycloak.models.mapper;
import org.mapstruct.factory.Mappers;
public class MapStructModelMapper implements ModelMapper {
private final MapStructClientModelMapper clientMapper;
public MapStructModelMapper() {
this.clientMapper = Mappers.getMapper(MapStructClientModelMapper.class);
}
@Override
public ClientModelMapper clients() {
return clientMapper;
}
}

View File

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

@@ -0,0 +1,26 @@
package org.keycloak.services;
import org.keycloak.models.KeycloakSession;
import org.keycloak.services.client.ClientService;
public class DefaultKeycloakServices implements KeycloakServices {
private final KeycloakSession session;
private ClientService clients;
public DefaultKeycloakServices(KeycloakSession session) {
this.session = session;
}
@Override
public ClientService clients() {
if (clients == null) {
clients = session.getProvider(ClientService.class);
}
return clients;
}
@Override
public void close() {
}
}

View File

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

View File

@@ -41,10 +41,10 @@ import org.keycloak.models.UserLoginFailureProvider;
import org.keycloak.models.UserProvider;
import org.keycloak.models.UserSessionProvider;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.InvalidationHandler.InvalidableObjectType;
import org.keycloak.provider.InvalidationHandler.ObjectType;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.services.clientpolicy.ClientPolicyManager;
import org.keycloak.sessions.AuthenticationSessionProvider;
import org.keycloak.storage.DatastoreProvider;
@@ -84,6 +84,7 @@ public abstract class DefaultKeycloakSession implements KeycloakSession {
private TokenManager tokenManager;
private VaultTranscriber vaultTranscriber;
private ClientPolicyManager clientPolicyManager;
private KeycloakServices services;
private boolean closed = false;
public DefaultKeycloakSession(DefaultKeycloakSessionFactory factory) {
@@ -340,6 +341,14 @@ public abstract class DefaultKeycloakSession implements KeycloakSession {
return clientPolicyManager;
}
@Override
public KeycloakServices services() {
if (services == null) {
services = getProvider(KeycloakServices.class);
}
return services;
}
private static final Logger LOG = Logger.getLogger(DefaultKeycloakSession.class);
@Override

View File

@@ -0,0 +1,64 @@
package org.keycloak.services.client;
import jakarta.ws.rs.core.Response;
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.services.ServiceException;
import java.util.Optional;
import java.util.stream.Stream;
// TODO
public class DefaultClientService implements ClientService {
private final KeycloakSession session;
private final ClientModelMapper mapper;
public DefaultClientService(KeycloakSession session) {
this.session = session;
this.mapper = session.getProvider(ModelMapper.class).clients();
}
@Override
public Optional<ClientRepresentation> getClient(RealmModel realm, String clientId) {
return Optional.ofNullable(realm.getClientByClientId(clientId)).map(mapper::fromModel);
}
@Override
public Optional<ClientRepresentation> getClient(RealmModel realm, String clientId, Boolean fullRepresentation) {
// TODO reduced client rep
return fullRepresentation != null && fullRepresentation ? getClient(realm, clientId) : Optional.of(getTestReducedClientRep(clientId));
}
@Override
public Stream<ClientRepresentation> getClients(RealmModel realm) {
return realm.getClientsStream().map(mapper::fromModel);
}
@Override
public ClientRepresentation createOrUpdateClient(RealmModel realm, ClientRepresentation client) throws ServiceException {
return null; // TODO
}
@Override
public ClientRepresentation createClient(RealmModel realm, ClientRepresentation client) throws ServiceException {
if (realm.getClientByClientId(client.getClientId()) != null) {
throw new ServiceException("Client already exists", Response.Status.CONFLICT);
}
var model = realm.addClient(client.getClientId());
return mapper.fromModel(model);
}
@Override
public void close() {
}
// TODO tested reduced client representation
private static ClientRepresentation getTestReducedClientRep(String clientId) {
return new ClientRepresentation(clientId);
}
}

View File

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

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

View File

@@ -0,0 +1 @@
org.keycloak.services.DefaultKeycloakServicesFactory

View File

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