[admin-v2] Polymorphism, refined OIDC Client representation (#44727)

* [admin-v2] Polymorphism, refined OIDC Client representation

Closes #43290

Signed-off-by: Václav Muzikář <vmuzikar@redhat.com>

* Remove AbstractRepModelMapper

Signed-off-by: Václav Muzikář <vmuzikar@redhat.com>

---------

Signed-off-by: Václav Muzikář <vmuzikar@redhat.com>
This commit is contained in:
Václav Muzikář 2026-01-06 16:23:30 +01:00 committed by GitHub
parent ffed84194e
commit ed69f332af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 952 additions and 673 deletions

View File

@ -226,8 +226,6 @@
<!-- 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>
@ -399,11 +397,6 @@
<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>

View File

@ -139,12 +139,6 @@
<artifactId>rdf-urdna</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<!-- Hibernate validator -->
<dependency>
<groupId>io.quarkus</groupId>

View File

@ -1,99 +1,246 @@
package org.keycloak.quarkus.runtime.oas;
import java.util.ArrayList;
import java.util.List;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.quarkus.smallrye.openapi.OpenApiFilter;
import org.eclipse.microprofile.openapi.OASFactory;
import org.eclipse.microprofile.openapi.OASFilter;
import org.eclipse.microprofile.openapi.models.OpenAPI;
import org.eclipse.microprofile.openapi.models.Operation;
import org.eclipse.microprofile.openapi.models.PathItem;
import org.eclipse.microprofile.openapi.models.media.Content;
import org.eclipse.microprofile.openapi.models.media.Discriminator;
import org.eclipse.microprofile.openapi.models.media.Schema;
import org.eclipse.microprofile.openapi.models.parameters.RequestBody;
import org.eclipse.microprofile.openapi.models.responses.APIResponses;
import org.jboss.jandex.AnnotationInstance;
import org.jboss.jandex.AnnotationValue;
import org.jboss.jandex.ClassInfo;
import org.jboss.jandex.IndexView;
import org.jboss.logging.Logger;
@OpenApiFilter(OpenApiFilter.RunStage.BUILD)
public class OASModelFilter implements OASFilter {
@Override
public void filterOpenAPI(OpenAPI openAPI) {
// Filter Paths that have the '/admin/api/v2' prefix
Map<String, PathItem> newPaths = openAPI.getPaths().getPathItems().entrySet().stream()
.filter(entry -> entry.getKey().startsWith("/admin/api/v2"))
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> sortOperationsByMethod(entry.getValue())
));
private final IndexView index;
private final Logger log = Logger.getLogger(OASModelFilter.class);
private final Map<String, ClassInfo> simpleNameToClassInfoMap = new HashMap<>();
// Replace ALL Paths with filtered Paths
var paths = OASFactory.createPaths();
newPaths.forEach(paths::addPathItem);
openAPI.setPaths(paths);
public static final String REF_PREFIX = "#/components/schemas/";
// Compute tags that are actually used by remaining operations
Set<String> usedTags = newPaths.values().stream()
.flatMap(pi -> operationsOf(pi).stream())
.flatMap(op -> Optional.ofNullable(op.getTags()).orElseGet(List::of).stream())
.collect(Collectors.toSet());
public OASModelFilter(IndexView indexView) {
this.index = indexView;
log.debug("Index size: " + indexView.getKnownClasses().size());
// Drop top-level tags not used anywhere
if (openAPI.getTags() != null) {
var filteredTags = openAPI.getTags().stream()
.filter(t -> t.getName() != null && usedTags.contains(t.getName()))
.collect(Collectors.toList());
openAPI.setTags(filteredTags.isEmpty() ? null : filteredTags);
}
}
private PathItem sortOperationsByMethod(PathItem pathItem) {
PathItem sortedPathItem = OASFactory.createPathItem();
// Add operations order: GET -> POST -> PUT -> PATCH -> DELETE -> HEAD -> OPTIONS -> TRACE
if (pathItem.getGET() != null) {
sortedPathItem.setGET(pathItem.getGET());
}
if (pathItem.getPOST() != null) {
sortedPathItem.setPOST(pathItem.getPOST());
}
if (pathItem.getPUT() != null) {
sortedPathItem.setPUT(pathItem.getPUT());
}
if (pathItem.getPATCH() != null) {
sortedPathItem.setPATCH(pathItem.getPATCH());
}
if (pathItem.getDELETE() != null) {
sortedPathItem.setDELETE(pathItem.getDELETE());
}
if (pathItem.getHEAD() != null) {
sortedPathItem.setHEAD(pathItem.getHEAD());
}
if (pathItem.getOPTIONS() != null) {
sortedPathItem.setOPTIONS(pathItem.getOPTIONS());
}
if (pathItem.getTRACE() != null) {
sortedPathItem.setTRACE(pathItem.getTRACE());
indexView.getKnownClasses().forEach(classInfo -> {
simpleNameToClassInfoMap.put(classInfo.simpleName(), classInfo);
});
}
sortedPathItem.setSummary(pathItem.getSummary());
sortedPathItem.setDescription(pathItem.getDescription());
sortedPathItem.setServers(pathItem.getServers());
sortedPathItem.setParameters(pathItem.getParameters());
@Override
public void filterOpenAPI(OpenAPI openAPI) {
// Sort Paths
Map<String, PathItem> newPaths = openAPI.getPaths().getPathItems().entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> sortOperationsByMethod(entry.getValue())
));
return sortedPathItem;
}
// Replace ALL Paths with sorted Paths
var paths = OASFactory.createPaths();
newPaths.forEach(paths::addPathItem);
openAPI.setPaths(paths);
private List<Operation> operationsOf(PathItem pi) {
List<Operation> ops = new ArrayList<>(8);
if (pi.getGET() != null) ops.add(pi.getGET());
if (pi.getPOST() != null) ops.add(pi.getPOST());
if (pi.getPUT() != null) ops.add(pi.getPUT());
if (pi.getPATCH() != null) ops.add(pi.getPATCH());
if (pi.getDELETE() != null) ops.add(pi.getDELETE());
if (pi.getHEAD() != null) ops.add(pi.getHEAD());
if (pi.getOPTIONS() != null) ops.add(pi.getOPTIONS());
if (pi.getTRACE() != null) ops.add(pi.getTRACE());
return ops;
}
Map<String, Set<Schema>> discriminatorPropertiesToBeAdded = new HashMap<>();
// Reflect Jackson annotations in OpenAPI spec
// Follows https://swagger.io/docs/specification/v3_0/data-models/inheritance-and-polymorphism/
openAPI.getPaths().getPathItems().values().stream()
.flatMap(p -> p.getOperations().values().stream())
.forEach(operation -> {
// This is not nice but so is the model structure...
// Request body
Optional.ofNullable(operation.getRequestBody())
.map(RequestBody::getContent)
.map(Content::getMediaTypes)
.map(Map::values)
.map(Collection::stream)
.ifPresent(mediaTypes -> {
mediaTypes.forEach(mediaType -> {
mediaType.setSchema(replaceSchemaWithChildrenIfNeeded(mediaType.getSchema(), openAPI, discriminatorPropertiesToBeAdded));
});
});
// Responses
Optional.ofNullable(operation.getResponses())
.map(APIResponses::getAPIResponses)
.map(Map::values)
.map(Collection::stream)
.ifPresent(apiResponses -> {
apiResponses.forEach(apiResponse -> {
Optional.ofNullable(apiResponse.getContent())
.map(Content::getMediaTypes)
.map(Map::values)
.map(Collection::stream)
.ifPresent(mediaTypes -> {
mediaTypes.forEach(mediaType -> {
mediaType.setSchema(replaceSchemaWithChildrenIfNeeded(mediaType.getSchema(), openAPI, discriminatorPropertiesToBeAdded));
});
});
});
});
});
// Add missing discriminator properties to subclass schemas
// Normally, this is handled by Jackson
discriminatorPropertiesToBeAdded.forEach((propertyName, schemas) -> {
schemas.forEach(schema -> {
if (schema.getProperties() == null || !schema.getProperties().containsKey(propertyName)) {
Schema discriminatorPropertySchema = OASFactory.createSchema().addType(Schema.SchemaType.STRING);
schema.addProperty(propertyName, discriminatorPropertySchema);
}
});
});
}
private PathItem sortOperationsByMethod(PathItem pathItem) {
PathItem sortedPathItem = OASFactory.createPathItem();
// Add operations order: GET -> POST -> PUT -> PATCH -> DELETE -> HEAD -> OPTIONS -> TRACE
if (pathItem.getGET() != null) {
sortedPathItem.setGET(pathItem.getGET());
}
if (pathItem.getPOST() != null) {
sortedPathItem.setPOST(pathItem.getPOST());
}
if (pathItem.getPUT() != null) {
sortedPathItem.setPUT(pathItem.getPUT());
}
if (pathItem.getPATCH() != null) {
sortedPathItem.setPATCH(pathItem.getPATCH());
}
if (pathItem.getDELETE() != null) {
sortedPathItem.setDELETE(pathItem.getDELETE());
}
if (pathItem.getHEAD() != null) {
sortedPathItem.setHEAD(pathItem.getHEAD());
}
if (pathItem.getOPTIONS() != null) {
sortedPathItem.setOPTIONS(pathItem.getOPTIONS());
}
if (pathItem.getTRACE() != null) {
sortedPathItem.setTRACE(pathItem.getTRACE());
}
sortedPathItem.setSummary(pathItem.getSummary());
sortedPathItem.setDescription(pathItem.getDescription());
sortedPathItem.setServers(pathItem.getServers());
sortedPathItem.setParameters(pathItem.getParameters());
return sortedPathItem;
}
/**
* Replaces the given schema with a new schema that uses oneOf to reference all subclasses if the original schema
* has a $ref and the referenced class has Jackson @JsonTypeInfo and @JsonSubTypes annotations. I.e. adds polymorphism
* support to OpenAPI generation.
*
* @param originalSchema
* @param openAPI
* @param discriminatorPropertiesToBeAdded
* @return the new schema or the original schema if no changes were made
*/
private Schema replaceSchemaWithChildrenIfNeeded(Schema originalSchema, OpenAPI openAPI, Map<String, Set<Schema>> discriminatorPropertiesToBeAdded) {
Schema arraySchema = null;
if (originalSchema.getType() != null && originalSchema.getType().size() == 1 && Schema.SchemaType.ARRAY.equals(originalSchema.getType().get(0))) {
arraySchema = originalSchema;
originalSchema = originalSchema.getItems();
}
if (originalSchema == null || originalSchema.getRef() == null) {
return originalSchema;
}
String parentSchemaName = originalSchema.getRef().substring(REF_PREFIX.length());
ClassInfo parentClassInfo = simpleNameToClassInfoMap.get(parentSchemaName);
if (parentClassInfo == null) {
throw new IllegalStateException("Could not find class in index for schema: " + parentSchemaName);
}
AnnotationInstance typeInfoAnnotation = parentClassInfo.annotation(JsonTypeInfo.class);
AnnotationInstance subTypesAnnotation = parentClassInfo.annotation(JsonSubTypes.class);
if (typeInfoAnnotation == null || subTypesAnnotation == null) {
log.debugf("Class %s does not have JsonTypeInfo or JsonSubTypes annotations, skipping", parentClassInfo.simpleName());
return originalSchema;
}
AnnotationInstance[] typeAnnotations = Optional.of(subTypesAnnotation.value()).map(AnnotationValue::asNestedArray).orElse(new AnnotationInstance[0]);
if (typeAnnotations.length == 0) {
log.debugf("Class %s does not have any JsonSubTypes defined, skipping", parentClassInfo.simpleName());
return originalSchema;
}
// Validations
AnnotationValue useValue = typeInfoAnnotation.value("use");
if (useValue == null || !JsonTypeInfo.Id.SIMPLE_NAME.name().equals(useValue.asEnum())) {
throw new IllegalArgumentException(parentClassInfo.simpleName() + ": JsonTypeInfo annotation must have use=SIMPLE_NAME.");
}
AnnotationValue includeValue = typeInfoAnnotation.value("include");
if (includeValue != null && !JsonTypeInfo.As.PROPERTY.name().equals(includeValue.asEnum())) {
throw new IllegalArgumentException(parentClassInfo.simpleName() + ": JsonTypeInfo annotation must have include=PROPERTY, or include must not be set.");
}
String discriminatorPropertyName = Optional.of(typeInfoAnnotation.value("property")).map(AnnotationValue::asString).orElse("");
if (discriminatorPropertyName.isEmpty()) {
throw new IllegalArgumentException(parentClassInfo.simpleName() + ": JsonTypeInfo annotation must have property set.");
}
Schema newSchema = OASFactory.createSchema();
// Add discriminator
Discriminator discriminator = OASFactory.createDiscriminator().propertyName(discriminatorPropertyName);
newSchema.setDiscriminator(discriminator);
// Create new schema with oneOf for each subclass
for (AnnotationInstance typeAnnotation : typeAnnotations) {
String simpleSubClassName = typeAnnotation.value("value").asClass().name().withoutPackagePrefix();
// Add schema ref as oneOf to the new schema
Schema subSchema = openAPI.getComponents().getSchemas().get(simpleSubClassName); // This won't work with inner classes due to '$' in the name
if (subSchema == null) {
throw new IllegalStateException(parentClassInfo.simpleName() + ": Could not find schema for subclass: " + simpleSubClassName + ". Make sure the subclass has the @Schema annotation.");
}
String ref = REF_PREFIX + simpleSubClassName;
Schema schemaRef = OASFactory.createSchema().ref(ref);
newSchema.addOneOf(schemaRef);
// Add mapping to discriminator
String typeName = Optional.of(typeAnnotation.value("name")).map(AnnotationValue::asString).orElse("");
if (!typeName.isEmpty()) {
discriminator.addMapping(typeName, ref);
}
discriminatorPropertiesToBeAdded.computeIfAbsent(discriminatorPropertyName, k -> new HashSet<>()).add(subSchema);
}
if (arraySchema != null) {
arraySchema.setItems(newSchema);
newSchema = arraySchema;
}
return newSchema;
}
}

View File

@ -100,8 +100,8 @@ quarkus.smallrye-openapi.store-schema-directory=${openapi.schema.target}
quarkus.swagger-ui.path=${quarkus.smallrye-openapi.path}/ui
quarkus.swagger-ui.always-include=true
quarkus.swagger-ui.filter=true
mp.openapi.filter=org.keycloak.quarkus.runtime.oas.OASModelFilter
mp.openapi.extensions.smallrye.remove-unused-schemas.enable=true
mp.openapi.scan.packages=org.keycloak.representations.admin.v2,org.keycloak.admin.api,org.keycloak.quarkus.runtime.oas,io.quarkus.smallrye.openapi
mp.openapi.extensions.smallrye.auto-inheritance=PARENT_ONLY
# Disable Error messages from smallrye.openapi
# related issue: https://github.com/keycloak/keycloak/issues/41871

View File

@ -25,10 +25,13 @@ import org.keycloak.it.junit5.extension.DryRun;
import org.keycloak.it.utils.KeycloakDistribution;
import io.quarkus.test.junit.main.Launch;
import io.restassured.response.ValidatableResponse;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static io.restassured.RestAssured.when;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.jupiter.api.Assertions.assertThrows;
@DistributionTest(keepAlive = true, requestPort = 9000, containerExposedPorts = {8080, 9000})
@ -87,4 +90,31 @@ public class OpenApiDistTest {
cliResult = dist.run("start-dev", "--openapi-enabled=true", "--features=client-admin-api:v2");
cliResult.assertError("Disabled option: '--openapi-enabled'. Available only when OpenAPI feature is enabled");
}
@Test
@Launch({"start-dev", "--openapi-enabled=true", FEATURES_OPTION})
void testOpenApiFilter(KeycloakDistribution distribution) {
ValidatableResponse response = given()
.header("Accept", "application/json")
.get(OPENAPI_ENDPOINT)
.then();
assertOpenAPISpecPolymorphicPaths(response, "paths.'/admin/api/v2/realms/{name}/clients/{id}'.put.requestBody.content.'application/json'.schema"); // request
assertOpenAPISpecPolymorphicPaths(response, "paths.'/admin/api/v2/realms/{name}/clients/{id}'.get.responses.'200'.content.'application/json'.schema"); // response
assertOpenAPISpecPolymorphicPaths(response, "paths.'/admin/api/v2/realms/{name}/clients'.get.responses.'200'.content.'application/json'.schema.items"); // arrays
response
.body("components.schemas.OIDCClientRepresentation.properties.protocol.type", equalTo("string")) // the generated discriminator field
.body("paths.'/admin/api/v2/realms/{name}/clients'.get.responses.'200'.content.'application/json'.schema.type", equalTo("array"));
}
private void assertOpenAPISpecPolymorphicPaths(ValidatableResponse response, String schemaPath) {
response
.body(schemaPath + ".discriminator.mapping.openid-connect", equalTo("#/components/schemas/OIDCClientRepresentation"))
.body(schemaPath + ".discriminator.mapping.saml", equalTo("#/components/schemas/SAMLClientRepresentation"))
.body(schemaPath + ".discriminator.propertyName", equalTo("protocol"))
.body(schemaPath + ".oneOf.size()", equalTo(2))
.body(schemaPath + ".oneOf[0].'$ref'", equalTo("#/components/schemas/OIDCClientRepresentation"))
.body(schemaPath + ".oneOf[1].'$ref'", equalTo("#/components/schemas/SAMLClientRepresentation"));
}
}

View File

@ -45,10 +45,6 @@
<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>
@ -64,13 +60,6 @@
<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>

View File

@ -0,0 +1,79 @@
package org.keycloak.models.mapper;
import java.util.HashSet;
import java.util.stream.Collectors;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public abstract class BaseClientModelMapper<T extends BaseClientRepresentation> implements ClientModelMapper {
protected final KeycloakSession session;
public BaseClientModelMapper(KeycloakSession session) {
this.session = session;
}
@Override
public BaseClientRepresentation fromModel(ClientModel model) {
// We don't want reps to depend on any unnecessary fields deps, hence no generated builder.
T rep = createClientRepresentation();
rep.setEnabled(model.isEnabled());
rep.setClientId(model.getClientId());
rep.setDescription(model.getDescription());
rep.setDisplayName(model.getName());
rep.setAppUrl(model.getBaseUrl());
rep.setRedirectUris(new HashSet<>(model.getRedirectUris()));
rep.setRoles(model.getRolesStream().map(RoleModel::getName).collect(Collectors.toSet()));
fromModelSpecific(model, rep);
return rep;
}
@Override
@SuppressWarnings("unchecked")
public ClientModel toModel(BaseClientRepresentation rep, ClientModel existingModel) {
if (existingModel == null) {
existingModel = createClientModel(rep);
}
existingModel.setEnabled(Boolean.TRUE.equals(rep.getEnabled()));
existingModel.setClientId(rep.getClientId());
existingModel.setDescription(rep.getDescription());
existingModel.setName(rep.getDisplayName());
existingModel.setBaseUrl(rep.getAppUrl());
existingModel.setRedirectUris(new HashSet<>(rep.getRedirectUris()));
// Roles are not handled here
toModelSpecific((T) rep, existingModel);
return existingModel;
}
protected ClientModel createClientModel(BaseClientRepresentation rep) {
RealmModel realm = session.getContext().getRealm();
// dummy add/remove to obtain a detached model
var model = realm.addClient(rep.getClientId());
realm.removeClient(model.getId());
return model;
}
protected abstract T createClientRepresentation();
protected abstract void fromModelSpecific(ClientModel model, T rep);
protected abstract void toModelSpecific(T rep, ClientModel model);
@Override
public void close() {
}
}

View File

@ -1,16 +1,11 @@
package org.keycloak.models.mapper;
import org.keycloak.models.ClientModel;
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.provider.Provider;
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
public interface ClientModelMapper {
ClientRepresentation fromModel(KeycloakSession session, ClientModel model);
ClientModel toModel(KeycloakSession session, RealmModel realm, ClientModel existingModel, ClientRepresentation rep) throws ServiceException;
ClientModel toModel(KeycloakSession session, RealmModel realm, ClientRepresentation rep) throws ServiceException;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public interface ClientModelMapper extends Provider, RepModelMapper<BaseClientRepresentation, ClientModel> {
}

View File

@ -0,0 +1,9 @@
package org.keycloak.models.mapper;
import org.keycloak.provider.ProviderFactory;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public interface ClientModelMapperFactory extends ProviderFactory<ClientModelMapper> {
}

View File

@ -0,0 +1,30 @@
package org.keycloak.models.mapper;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public class ClientModelMapperSpi implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "client-model-mapper";
}
@Override
public Class<? extends Provider> getProviderClass() {
return ClientModelMapper.class;
}
@Override
public Class<? extends ProviderFactory<ClientModelMapper>> getProviderFactoryClass() {
return ClientModelMapperFactory.class;
}
}

View File

@ -1,103 +0,0 @@
package org.keycloak.models.mapper;
import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.services.ServiceException;
import org.mapstruct.Context;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.Named;
import org.mapstruct.ObjectFactory;
@Mapper
public interface MapStructClientModelMapper extends ClientModelMapper {
@Override
@ModelToRep
ClientRepresentation fromModel(@Context KeycloakSession session, ClientModel model);
// we don't want to ignore nulls so that we completely overwrite the state
@Override
@RepToModel
ClientModel toModel(@Context KeycloakSession session, @Context RealmModel realm, @MappingTarget ClientModel existingModel, ClientRepresentation rep) throws ServiceException;
@Override
@RepToModel
ClientModel toModel(@Context KeycloakSession session, @Context RealmModel realm, ClientRepresentation rep) throws ServiceException;
/*-------------------------------------*
* MAPPERS *
*-------------------------------------*/
@Mapping(target = "name", source = "displayName")
@Mapping(target = "baseUrl", source = "appUrl")
@Mapping(target = "redirectUris", source = "appRedirectUrls")
@Mapping(target = "authenticationFlowBindingOverrides", source = "loginFlows", ignore = true) // TODO
@Mapping(target = "publicClient", source = "auth.enabled", qualifiedByName = "isPublicClientPrimitive")
@Mapping(target = "clientAuthenticatorType", source = "auth.method")
@Mapping(target = "secret", source = "auth.secret")
@Mapping(target = "serviceAccountsEnabled", source = "serviceAccount.enabled")
@interface RepToModel {
}
@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.enabled", source = "publicClient", qualifiedByName = "isPublicClient")
@Mapping(target = "auth.method", source = "clientAuthenticatorType")
@Mapping(target = "auth.secret", source = "secret")
@Mapping(target = "auth.certificate", ignore = true) // no cert in the representation
@Mapping(target = "roles", source = "rolesStream", qualifiedByName = "getRoleStrings")
@Mapping(target = "serviceAccount.enabled", source = "serviceAccountsEnabled")
@Mapping(target = "serviceAccount.roles", source = ".", qualifiedByName = "getServiceAccountRoles")
@interface ModelToRep {
}
/*-------------------------------------*
* HELPER METHODS *
*-------------------------------------*/
@ObjectFactory
default ClientModel createClientModel(@Context RealmModel realm, ClientRepresentation rep) {
// dummy add/remove to obtain a detached model
var model = realm.addClient(rep.getClientId());
realm.removeClient(model.getId());
return model;
}
@Named("isPublicClientPrimitive")
default boolean isPublicClientPrimitive(Boolean authEnabled) {
var result = isPublicClient(authEnabled);
return result != null ? result : false;
}
@Named("isPublicClient")
default Boolean isPublicClient(Boolean authEnabled) {
return authEnabled != null ? !authEnabled : null;
}
@Named("getRoleStrings")
default Set<String> getRoleStrings(Stream<RoleModel> stream) {
return stream.map(RoleModel::getName).collect(Collectors.toSet());
}
@Named("getServiceAccountRoles")
default Set<String> getServiceAccountRoles(@Context KeycloakSession session, ClientModel client) {
if (client.isServiceAccountsEnabled()) {
return session.users().getServiceAccount(client)
.getRoleMappingsStream()
.map(RoleModel::getName)
.collect(Collectors.toSet());
}
return Collections.emptySet();
}
}

View File

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

@ -1,7 +0,0 @@
package org.keycloak.models.mapper;
public interface ModelMapper {
ClientModelMapper clients();
}

View File

@ -0,0 +1,92 @@
package org.keycloak.models.mapper;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RoleModel;
import org.keycloak.representations.admin.v2.OIDCClientRepresentation;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public class OIDCClientModelMapper extends BaseClientModelMapper<OIDCClientRepresentation> {
public OIDCClientModelMapper(KeycloakSession session) {
super(session);
}
@Override
protected OIDCClientRepresentation createClientRepresentation() {
return new OIDCClientRepresentation();
}
@Override
protected void fromModelSpecific(ClientModel model, OIDCClientRepresentation rep) {
rep.setLoginFlows(createLoginFlows(model));
if (!model.isPublicClient()) {
OIDCClientRepresentation.Auth auth = new OIDCClientRepresentation.Auth();
auth.setMethod(model.getClientAuthenticatorType());
auth.setSecret(model.getSecret());
rep.setAuth(auth);
// TODO: auth.certificate
}
rep.setWebOrigins(new HashSet<>(model.getWebOrigins()));
rep.setServiceAccountRoles(getServiceAccountRoles(model));
}
@Override
protected void toModelSpecific(OIDCClientRepresentation rep, ClientModel model) {
if (rep.getAuth() != null) {
model.setPublicClient(false);
model.setClientAuthenticatorType(rep.getAuth().getMethod());
model.setSecret(rep.getAuth().getSecret());
} else {
model.setPublicClient(true);
}
setModelFromFlows(rep.getLoginFlows(), model);
model.setWebOrigins(new HashSet<>(rep.getWebOrigins()));
// Service account roles are not handled here
}
private Set<OIDCClientRepresentation.Flow> createLoginFlows(ClientModel model) {
Set<OIDCClientRepresentation.Flow> flows = new HashSet<>();
if (model.isStandardFlowEnabled()) {
flows.add(OIDCClientRepresentation.Flow.STANDARD);
}
if (model.isImplicitFlowEnabled()) {
flows.add(OIDCClientRepresentation.Flow.IMPLICIT);
}
if (model.isDirectAccessGrantsEnabled()) {
flows.add(OIDCClientRepresentation.Flow.DIRECT_GRANT);
}
// TODO: device flow, token exchange, ciba
if (model.isServiceAccountsEnabled()) {
flows.add(OIDCClientRepresentation.Flow.SERVICE_ACCOUNT);
}
return flows;
}
private void setModelFromFlows(Set<OIDCClientRepresentation.Flow> flows, ClientModel model) {
model.setStandardFlowEnabled(flows.contains(OIDCClientRepresentation.Flow.STANDARD));
model.setImplicitFlowEnabled(flows.contains(OIDCClientRepresentation.Flow.IMPLICIT));
model.setDirectAccessGrantsEnabled(flows.contains(OIDCClientRepresentation.Flow.DIRECT_GRANT));
}
private Set<String> getServiceAccountRoles(ClientModel client) {
if (client.isServiceAccountsEnabled()) {
return session.users().getServiceAccount(client)
.getRoleMappingsStream()
.map(RoleModel::getName)
.collect(Collectors.toSet());
}
return Collections.emptySet();
}
}

View File

@ -0,0 +1,33 @@
package org.keycloak.models.mapper;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.representations.admin.v2.OIDCClientRepresentation;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public class OIDCClientModelMapperFactory implements ClientModelMapperFactory {
@Override
public ClientModelMapper create(KeycloakSession session) {
return new OIDCClientModelMapper(session);
}
@Override
public String getId() {
return OIDCClientRepresentation.PROTOCOL;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@ -0,0 +1,14 @@
package org.keycloak.models.mapper;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
public interface RepModelMapper <T, U> {
T fromModel(U model);
default U toModel(T rep) {
return toModel(rep, null);
}
U toModel(T rep, U existingModel);
}

View File

@ -0,0 +1,131 @@
package org.keycloak.representations.admin.v2;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import jakarta.validation.constraints.NotBlank;
import org.keycloak.representations.admin.v2.validation.CreateClient;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.hibernate.validator.constraints.URL;
@JsonTypeInfo(
use = JsonTypeInfo.Id.SIMPLE_NAME,
include = JsonTypeInfo.As.PROPERTY,
property = BaseClientRepresentation.DISCRIMINATOR_FIELD
)
@JsonSubTypes({
@JsonSubTypes.Type(value = OIDCClientRepresentation.class, name = "openid-connect"),
@JsonSubTypes.Type(value = SAMLClientRepresentation.class, name = "saml")
})
public abstract class BaseClientRepresentation extends BaseRepresentation {
public static final String DISCRIMINATOR_FIELD = "protocol";
@NotBlank(groups = CreateClient.class)
@JsonPropertyDescription("ID uniquely identifying this client")
protected String clientId;
@JsonPropertyDescription("Human readable name of the client")
private String displayName;
@JsonPropertyDescription("Human readable description of the client")
private String description;
@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("URIs that the browser can redirect to after login")
private Set<@NotBlank @URL(message = "Each redirect URL must be valid") String> redirectUris = new LinkedHashSet<>();
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonPropertyDescription("Roles associated with this client")
private Set<@NotBlank String> roles = new LinkedHashSet<>();
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 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> getRedirectUris() {
return redirectUris;
}
public void setRedirectUris(Set<String> redirectUris) {
this.redirectUris = redirectUris;
}
public Set<String> getRoles() {
return roles;
}
public void setRoles(Set<String> roles) {
this.roles = roles;
}
@JsonIgnore
public abstract String getProtocol();
@JsonAnyGetter
public Map<String, Object> getAdditionalFields() {
return additionalFields;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof BaseClientRepresentation that)) return false;
return Objects.equals(clientId, that.clientId) && Objects.equals(displayName, that.displayName) && Objects.equals(description, that.description) && Objects.equals(enabled, that.enabled) && Objects.equals(appUrl, that.appUrl) && Objects.equals(redirectUris, that.redirectUris) && Objects.equals(roles, that.roles) && Objects.equals(additionalFields, that.additionalFields);
}
@Override
public int hashCode() {
return Objects.hash(clientId, displayName, description, enabled, appUrl, redirectUris, roles, additionalFields);
}
}

View File

@ -7,8 +7,10 @@ import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@Schema
public class BaseRepresentation {
@JsonIgnore

View File

@ -1,297 +0,0 @@
package org.keycloak.representations.admin.v2;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.keycloak.representations.admin.v2.validation.CreateClient;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import org.hibernate.validator.constraints.URL;
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;
@JsonPropertyDescription("Human readable name of the client")
private String displayName;
@JsonPropertyDescription("Human readable description of the client")
private String description;
@JsonProperty(defaultValue = OIDC)
@JsonPropertyDescription("The protocol used to communicate with the client")
private String protocol;
@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<@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<@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<@NotBlank String> webOrigins = new LinkedHashSet<String>();
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonPropertyDescription("Roles associated with this client")
private Set<@NotBlank String> roles = new LinkedHashSet<String>();
@Valid
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@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_ABSENT)
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;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Auth auth)) return false;
return Objects.equals(enabled, auth.enabled)
&& Objects.equals(method, auth.method)
&& Objects.equals(secret, auth.secret)
&& Objects.equals(certificate, auth.certificate);
}
@Override
public int hashCode() {
return Objects.hash(enabled, method, secret, certificate);
}
}
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public static class ServiceAccount {
@JsonPropertyDescription("Whether the service account is enabled")
private Boolean enabled;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonPropertyDescription("Roles assigned to the service account")
private Set<String> roles = new LinkedHashSet<String>();
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;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof ServiceAccount that)) return false;
return Objects.equals(enabled, that.enabled)
&& Objects.equals(roles, that.roles);
}
@Override
public int hashCode() {
return Objects.hash(enabled, roles);
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ClientRepresentation that = (ClientRepresentation) o;
return Objects.equals(clientId, that.clientId)
&& Objects.equals(displayName, that.displayName)
&& Objects.equals(description, that.description)
&& Objects.equals(protocol, that.protocol)
&& Objects.equals(enabled, that.enabled)
&& Objects.equals(appUrl, that.appUrl)
&& Objects.equals(appRedirectUrls, that.appRedirectUrls)
&& Objects.equals(loginFlows, that.loginFlows)
&& Objects.equals(auth, that.auth)
&& Objects.equals(webOrigins, that.webOrigins)
&& Objects.equals(roles, that.roles)
&& Objects.equals(serviceAccount, that.serviceAccount);
}
@Override
public int hashCode() {
return Objects.hash(clientId, displayName, description, protocol, enabled, appUrl, appRedirectUrls,
loginFlows, auth, webOrigins, roles, serviceAccount
);
}
}

View File

@ -0,0 +1,146 @@
package org.keycloak.representations.admin.v2;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
@Schema
public class OIDCClientRepresentation extends BaseClientRepresentation {
public static final String PROTOCOL = "openid-connect";
public enum Flow {
STANDARD,
IMPLICIT,
DIRECT_GRANT,
SERVICE_ACCOUNT,
TOKEN_EXCHANGE,
DEVICE,
CIBA
}
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonPropertyDescription("Login flows that are enabled for this client")
private Set<Flow> loginFlows = new LinkedHashSet<>();
@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<@NotBlank String> webOrigins = new LinkedHashSet<>();
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonPropertyDescription("Roles assigned to the service account")
private Set<@NotBlank String> serviceAccountRoles = new LinkedHashSet<>();
public OIDCClientRepresentation() {}
public OIDCClientRepresentation(String clientId) {
this.clientId = clientId;
}
public Set<Flow> getLoginFlows() {
return loginFlows;
}
public void setLoginFlows(Set<Flow> 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> getServiceAccountRoles() {
return serviceAccountRoles;
}
public void setServiceAccountRoles(Set<String> serviceAccountRoles) {
this.serviceAccountRoles = serviceAccountRoles;
}
@Override
public String getProtocol() {
return PROTOCOL;
}
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public static class Auth {
@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 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;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Auth auth)) return false;
return Objects.equals(method, auth.method) && Objects.equals(secret, auth.secret) && Objects.equals(certificate, auth.certificate);
}
@Override
public int hashCode() {
return Objects.hash(method, secret, certificate);
}
}
@Override
public boolean equals(Object o) {
if (!(o instanceof OIDCClientRepresentation that)) return false;
if (!super.equals(o)) return false;
return Objects.equals(loginFlows, that.loginFlows) && Objects.equals(auth, that.auth) && Objects.equals(webOrigins, that.webOrigins) && Objects.equals(serviceAccountRoles, that.serviceAccountRoles);
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), loginFlows, auth, webOrigins, serviceAccountRoles);
}
}

View File

@ -0,0 +1,16 @@
package org.keycloak.representations.admin.v2;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
/**
* @author Vaclav Muzikar <vmuzikar@redhat.com>
*/
@Schema
public class SAMLClientRepresentation extends BaseClientRepresentation {
public static final String PROTOCOL = "saml";
@Override
public String getProtocol() {
return PROTOCOL;
}
}

View File

@ -4,7 +4,7 @@ import java.util.Optional;
import java.util.stream.Stream;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
import org.keycloak.services.Service;
import org.keycloak.services.ServiceException;
@ -25,14 +25,14 @@ public interface ClientService extends Service {
// NOTE: this is not always the most desirable way to do pagination
}
record CreateOrUpdateResult(ClientRepresentation representation, boolean created) {}
record CreateOrUpdateResult(BaseClientRepresentation representation, boolean created) {}
Optional<ClientRepresentation> getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions);
Optional<BaseClientRepresentation> getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions);
Stream<ClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions, ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions);
Stream<BaseClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions, ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions);
Stream<ClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions);
Stream<BaseClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions);
CreateOrUpdateResult createOrUpdate(RealmModel realm, ClientRepresentation client, boolean allowUpdate) throws ServiceException;
CreateOrUpdateResult createOrUpdate(RealmModel realm, BaseClientRepresentation client, boolean allowUpdate) throws ServiceException;
}

View File

@ -15,9 +15,9 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
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.BaseClientRepresentation;
import org.keycloak.representations.admin.v2.OIDCClientRepresentation;
import org.keycloak.representations.admin.v2.validation.CreateClientDefault;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.services.ServiceException;
@ -30,7 +30,6 @@ import org.keycloak.validation.jakarta.JakartaValidatorProvider;
// TODO
public class DefaultClientService implements ClientService {
private final KeycloakSession session;
private final ClientModelMapper mapper;
private final JakartaValidatorProvider validator;
private final RealmAdminResource realmAdminResource;
private final ClientsResource clientsResource;
@ -42,7 +41,6 @@ public class DefaultClientService implements ClientService {
this.clientResource = clientResource;
this.clientsResource = realmAdminResource.getClients();
this.mapper = new MapStructModelMapper().clients();
this.validator = new HibernateValidatorProvider();
}
@ -51,48 +49,58 @@ public class DefaultClientService implements ClientService {
}
@Override
public Optional<ClientRepresentation> getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions) {
public Optional<BaseClientRepresentation> getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions) {
// TODO: is the access map on the representation needed
return Optional.ofNullable(clientResource).map(ClientResource::viewClientModel).map(model -> mapper.fromModel(session, model));
return Optional.ofNullable(clientResource).map(ClientResource::viewClientModel)
.map(model -> session.getProvider(ClientModelMapper.class, model.getProtocol()).fromModel(model));
}
@Override
public Stream<ClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions,
public Stream<BaseClientRepresentation> getClients(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(model -> mapper.fromModel(session, model));
return clientsResource.getClientModels(null, true, false, null, null, null)
.map(model -> session.getProvider(ClientModelMapper.class, model.getProtocol()).fromModel(model));
}
@Override
public CreateOrUpdateResult createOrUpdate(RealmModel realm, ClientRepresentation client, boolean allowUpdate) throws ServiceException {
public CreateOrUpdateResult createOrUpdate(RealmModel realm, BaseClientRepresentation client, boolean allowUpdate) throws ServiceException {
boolean created = false;
ClientModel model;
ClientModelMapper mapper = session.getProvider(ClientModelMapper.class, client.getProtocol());
if (mapper == null) {
throw new ServiceException("Mapper not found, unsupported client protocol: " + client.getProtocol(), Response.Status.BAD_REQUEST);
}
if (clientResource != null) {
if (!allowUpdate) {
throw new ServiceException("Client already exists", Response.Status.CONFLICT);
}
model = mapper.toModel(session, realm, clientResource.viewClientModel(), client);
model = mapper.toModel(client, clientResource.viewClientModel());
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
model = mapper.toModel(session, realm, client);
model = mapper.toModel(client);
var rep = ModelToRepresentation.toRepresentation(model, session);
model = clientsResource.createClientModel(rep);
clientResource = clientsResource.getClient(model.getId());
}
handleRoles(client.getRoles());
handleServiceAccount(model, client.getServiceAccount());
var updated = mapper.fromModel(session, model);
if (client instanceof OIDCClientRepresentation oidcClient) {
handleServiceAccount(model, oidcClient);
}
var updated = mapper.fromModel(model);
return new CreateOrUpdateResult(updated, created);
}
@Override
public Stream<ClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions) {
public Stream<BaseClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions) {
// TODO Auto-generated method stub
return null;
}
@ -128,56 +136,58 @@ public class DefaultClientService implements ClientService {
* <p>
* Reuses API v1 logic
*/
protected void handleServiceAccount(ClientModel model, ClientRepresentation.ServiceAccount serviceAccount) {
if (serviceAccount != null && serviceAccount.getEnabled() != null) {
ClientResource.updateClientServiceAccount(session, model, serviceAccount.getEnabled());
protected void handleServiceAccount(ClientModel model, OIDCClientRepresentation rep) {
boolean serviceAccountEnabled = rep.getLoginFlows().contains(OIDCClientRepresentation.Flow.SERVICE_ACCOUNT);
if (serviceAccount.getEnabled()) {
var clientRoleResource = clientResource.getRoleContainerResource();
var realmRoleResource = realmAdminResource.getRoleContainerResource();
ClientResource.updateClientServiceAccount(session, model, serviceAccountEnabled);
var serviceAccountUser = session.users().getServiceAccount(model);
var serviceAccountRoleResource = realmAdminResource.users().user(clientResource.getServiceAccountUser().getId()).getRoleMappings();
if (!serviceAccountEnabled) {
return;
}
Set<String> desiredRoleNames = Optional.ofNullable(serviceAccount.getRoles()).orElse(Collections.emptySet());
Set<RoleModel> currentRoles = serviceAccountUser.getRoleMappingsStream().collect(Collectors.toSet());
Set<String> currentRoleNames = currentRoles.stream().map(RoleModel::getName).collect(Collectors.toSet());
var clientRoleResource = clientResource.getRoleContainerResource();
var realmRoleResource = realmAdminResource.getRoleContainerResource();
// Get missing roles (in desired but not in current)
List<RoleRepresentation> missingRoles = desiredRoleNames.stream()
.filter(roleName -> !currentRoleNames.contains(roleName))
.map(roleName -> {
try {
return clientRoleResource.getRole(roleName); // client role
} catch (NotFoundException e) {
try {
return realmRoleResource.getRole(roleName); // realm role
} catch (NotFoundException e2) {
throw new ServiceException("Cannot assign role to the service account (field 'serviceAccount.roles') as it does not exist", Response.Status.BAD_REQUEST);
}
}
})
.toList();
var serviceAccountUser = session.users().getServiceAccount(model);
var serviceAccountRoleResource = realmAdminResource.users().user(clientResource.getServiceAccountUser().getId()).getRoleMappings();
// Add missing roles (in desired but not in current)
if (!missingRoles.isEmpty()) {
serviceAccountRoleResource.addRealmRoleMappings(missingRoles);
}
Set<String> desiredRoleNames = Optional.ofNullable(rep.getServiceAccountRoles()).orElse(Collections.emptySet());
Set<RoleModel> currentRoles = serviceAccountUser.getRoleMappingsStream().collect(Collectors.toSet());
Set<String> currentRoleNames = currentRoles.stream().map(RoleModel::getName).collect(Collectors.toSet());
// Get extra roles (in current but not in desired)
List<RoleRepresentation> extraRoles = currentRoles.stream()
.filter(role -> !desiredRoleNames.contains(role.getName()))
.map(ModelToRepresentation::toRepresentation)
.toList();
// Remove extra roles (in current but not in desired)
if (!extraRoles.isEmpty()) {
// Get missing roles (in desired but not in current)
List<RoleRepresentation> missingRoles = desiredRoleNames.stream()
.filter(roleName -> !currentRoleNames.contains(roleName))
.map(roleName -> {
try {
serviceAccountRoleResource.deleteRealmRoleMappings(extraRoles);
return clientRoleResource.getRole(roleName); // client role
} catch (NotFoundException e) {
throw new ServiceException("Cannot unassign role from the service account (field 'serviceAccount.roles') as it does not exist", Response.Status.BAD_REQUEST);
try {
return realmRoleResource.getRole(roleName); // realm role
} catch (NotFoundException e2) {
throw new ServiceException("Cannot assign role to the service account (field 'serviceAccount.roles') as it does not exist", Response.Status.BAD_REQUEST);
}
}
}
})
.toList();
// Add missing roles (in desired but not in current)
if (!missingRoles.isEmpty()) {
serviceAccountRoleResource.addRealmRoleMappings(missingRoles);
}
// Get extra roles (in current but not in desired)
List<RoleRepresentation> extraRoles = currentRoles.stream()
.filter(role -> !desiredRoleNames.contains(role.getName()))
.map(ModelToRepresentation::toRepresentation)
.toList();
// Remove extra roles (in current but not in desired)
if (!extraRoles.isEmpty()) {
try {
serviceAccountRoleResource.deleteRealmRoleMappings(extraRoles);
} catch (NotFoundException e) {
throw new ServiceException("Cannot unassign role from the service account (field 'serviceAccount.roles') as it does not exist", Response.Status.BAD_REQUEST);
}
}
}

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
import com.fasterxml.jackson.databind.JsonNode;
@ -21,20 +21,20 @@ public interface ClientApi {
@GET
@Produces(MediaType.APPLICATION_JSON)
ClientRepresentation getClient();
BaseClientRepresentation getClient();
/**
* @return {@link ClientRepresentation} of created/updated client
* @return {@link BaseClientRepresentation} of created/updated client
*/
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
Response createOrUpdateClient(@Valid ClientRepresentation client);
Response createOrUpdateClient(@Valid BaseClientRepresentation client);
@PATCH
@Consumes(CONTENT_TYPE_MERGE_PATCH)
@Produces(MediaType.APPLICATION_JSON)
ClientRepresentation patchClient(JsonNode patch);
BaseClientRepresentation patchClient(JsonNode patch);
@DELETE
@Produces(MediaType.APPLICATION_JSON)

View File

@ -12,7 +12,7 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
import org.keycloak.services.resources.KeycloakOpenAPI;
import org.eclipse.microprofile.openapi.annotations.Operation;
@ -27,16 +27,16 @@ public interface ClientsApi {
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Operation(summary = "Get all clients", description = "Returns a list of all clients in the realm")
Stream<ClientRepresentation> getClients();
Stream<BaseClientRepresentation> getClients();
/**
* @return {@link ClientRepresentation} of created client
* @return {@link BaseClientRepresentation} of created client
*/
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create a new client", description = "Creates a new client in the realm")
Response createClient(@Valid ClientRepresentation client);
Response createClient(@Valid BaseClientRepresentation client);
@Path("{id}")
ClientApi client(@PathParam("id") String id);

View File

@ -12,7 +12,7 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.ServiceException;
import org.keycloak.services.client.ClientService;
@ -52,13 +52,13 @@ public class DefaultClientApi implements ClientApi {
}
@Override
public ClientRepresentation getClient() {
public BaseClientRepresentation getClient() {
return clientService.getClient(realm, client.getClientId(), null)
.orElseThrow(() -> new NotFoundException("Cannot find the specified client"));
}
@Override
public Response createOrUpdateClient(ClientRepresentation client) {
public Response createOrUpdateClient(BaseClientRepresentation client) {
try {
if (!Objects.equals(clientId, client.getClientId())) {
throw new WebApplicationException("cliendId in payload does not match the clientId in the path", Response.Status.BAD_REQUEST);
@ -72,8 +72,8 @@ public class DefaultClientApi implements ClientApi {
}
@Override
public ClientRepresentation patchClient(JsonNode patch) {
ClientRepresentation client = getClient();
public BaseClientRepresentation patchClient(JsonNode patch) {
BaseClientRepresentation client = getClient();
try {
String contentType = session.getContext().getHttpRequest().getHttpHeaders().getHeaderString(HttpHeaders.CONTENT_TYPE);
MediaType mediaType = contentType == null ? null : MediaType.valueOf(contentType);
@ -83,7 +83,7 @@ public class DefaultClientApi implements ClientApi {
}
final ObjectReader objectReader = objectMapper.readerForUpdating(client);
ClientRepresentation updated = objectReader.readValue(patch);
BaseClientRepresentation updated = objectReader.readValue(patch);
validateUnknownFields(updated);
return clientService.createOrUpdate(realm, updated, true).representation();
@ -104,8 +104,8 @@ public class DefaultClientApi implements ClientApi {
clientResource.deleteClient();
}
static void validateUnknownFields(ClientRepresentation rep) {
if (!rep.getAdditionalFields().isEmpty()) {
static void validateUnknownFields(BaseClientRepresentation rep) {
if (rep.getAdditionalFields().keySet().stream().anyMatch(k -> !k.equals(BaseClientRepresentation.DISCRIMINATOR_FIELD))) {
throw new WebApplicationException("Payload contains unknown fields: " + rep.getAdditionalFields().keySet(), Response.Status.BAD_REQUEST);
}
}

View File

@ -11,7 +11,7 @@ import jakarta.ws.rs.core.Response;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
import org.keycloak.representations.admin.v2.validation.CreateClientDefault;
import org.keycloak.services.ServiceException;
import org.keycloak.services.client.ClientService;
@ -40,12 +40,12 @@ public class DefaultClientsApi implements ClientsApi {
}
@Override
public Stream<ClientRepresentation> getClients() {
public Stream<BaseClientRepresentation> getClients() {
return clientService.getClients(realm, null, null, null);
}
@Override
public Response createClient(@Valid ClientRepresentation client) {
public Response createClient(@Valid BaseClientRepresentation client) {
try {
DefaultClientApi.validateUnknownFields(client);
validator.validate(client, CreateClientDefault.class);

View File

@ -25,7 +25,7 @@ import jakarta.ws.rs.core.MediaType;
import org.keycloak.admin.api.client.ClientApi;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.common.Profile;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.representations.admin.v2.OIDCClientRepresentation;
import org.keycloak.services.error.ViolationExceptionResponse;
import org.keycloak.testframework.annotations.InjectAdminClient;
import org.keycloak.testframework.annotations.InjectHttpClient;
@ -85,7 +85,7 @@ public class ClientApiV2Test {
setAuthHeader(request);
try (var response = client.execute(request)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
OIDCClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertEquals("account", client.getClientId());
}
}
@ -107,7 +107,7 @@ public class ClientApiV2Test {
setAuthHeader(request);
request.setHeader(HttpHeaders.CONTENT_TYPE, ClientApi.CONTENT_TYPE_MERGE_PATCH);
ClientRepresentation patch = new ClientRepresentation();
OIDCClientRepresentation patch = new OIDCClientRepresentation();
patch.setDescription("I'm also a description");
request.setEntity(new StringEntity(mapper.writeValueAsString(patch)));
@ -115,7 +115,7 @@ public class ClientApiV2Test {
try (var response = client.execute(request)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
OIDCClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertEquals("I'm also a description", client.getDescription());
}
}
@ -126,7 +126,7 @@ public class ClientApiV2Test {
setAuthHeader(request);
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
ClientRepresentation rep = new ClientRepresentation();
OIDCClientRepresentation rep = new OIDCClientRepresentation();
rep.setClientId("other");
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
@ -142,7 +142,7 @@ public class ClientApiV2Test {
setAuthHeader(request);
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
ClientRepresentation rep = new ClientRepresentation();
OIDCClientRepresentation rep = new OIDCClientRepresentation();
rep.setEnabled(true);
rep.setClientId("other");
rep.setDescription("I'm new");
@ -151,7 +151,7 @@ public class ClientApiV2Test {
try (var response = client.execute(request)) {
assertEquals(201, response.getStatusLine().getStatusCode());
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
OIDCClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertEquals("I'm new", client.getDescription());
}
@ -160,7 +160,7 @@ public class ClientApiV2Test {
try (var response = client.execute(request)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
OIDCClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertEquals("I'm updated", client.getDescription());
}
}
@ -171,7 +171,7 @@ public class ClientApiV2Test {
setAuthHeader(request);
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
ClientRepresentation rep = new ClientRepresentation();
OIDCClientRepresentation rep = new OIDCClientRepresentation();
rep.setEnabled(true);
rep.setClientId("client-123");
rep.setDescription("I'm new");
@ -180,7 +180,7 @@ public class ClientApiV2Test {
try (var response = client.execute(request)) {
assertThat(response.getStatusLine().getStatusCode(),is(201));
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
OIDCClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertThat(client.getEnabled(),is(true));
assertThat(client.getClientId(),is("client-123"));
assertThat(client.getDescription(),is("I'm new"));
@ -197,7 +197,7 @@ public class ClientApiV2Test {
setAuthHeader(createRequest);
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
ClientRepresentation rep = new ClientRepresentation();
OIDCClientRepresentation rep = new OIDCClientRepresentation();
rep.setClientId("to-delete");
rep.setEnabled(true);
@ -225,13 +225,14 @@ public class ClientApiV2Test {
}
@Test
public void clientRepresentationValidation() throws Exception {
public void OIDCClientRepresentationValidation() throws Exception {
HttpPost request = new HttpPost(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients");
setAuthHeader(request);
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
request.setEntity(new StringEntity("""
{
"protocol": "openid-connect",
"displayName": "something",
"appUrl": "notUrl"
}
@ -250,6 +251,7 @@ public class ClientApiV2Test {
request.setEntity(new StringEntity("""
{
"protocol": "openid-connect",
"clientId": "some-client",
"displayName": "something",
"appUrl": "notUrl",
@ -285,12 +287,12 @@ public class ClientApiV2Test {
setAuthHeader(request);
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
ClientRepresentation rep = getTestingFullClientRep();
OIDCClientRepresentation rep = getTestingFullClientRep();
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(request)) {
assertEquals(201, response.getStatusLine().getStatusCode());
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
OIDCClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertThat(client, is(rep));
}
}
@ -301,8 +303,8 @@ public class ClientApiV2Test {
setAuthHeader(request);
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
ClientRepresentation rep = getTestingFullClientRep();
rep.getServiceAccount().setRoles(Set.of("non-existing", "bad-role"));
OIDCClientRepresentation rep = getTestingFullClientRep();
rep.setServiceAccountRoles(Set.of("non-existing", "bad-role"));
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(request)) {
@ -318,7 +320,7 @@ public class ClientApiV2Test {
setAuthHeader(createRequest);
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
ClientRepresentation rep = new ClientRepresentation();
OIDCClientRepresentation rep = new OIDCClientRepresentation();
rep.setClientId("declarative-role-test");
rep.setEnabled(true);
rep.setRoles(Set.of("role1", "role2", "role3"));
@ -327,7 +329,7 @@ public class ClientApiV2Test {
try (var response = client.execute(createRequest)) {
assertEquals(201, response.getStatusLine().getStatusCode());
ClientRepresentation created = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
OIDCClientRepresentation created = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertThat(created.getRoles(), is(Set.of("role1", "role2", "role3")));
}
@ -341,7 +343,7 @@ public class ClientApiV2Test {
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
OIDCClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertThat(updated.getRoles(), is(Set.of("new-role1", "new-role2")));
}
@ -351,7 +353,7 @@ public class ClientApiV2Test {
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
OIDCClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertThat(updated.getRoles(), is(Set.of("new-role1", "add-role3", "add-role4")));
}
@ -361,7 +363,7 @@ public class ClientApiV2Test {
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
OIDCClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertThat(updated.getRoles(), is(Set.of("new-role1", "add-role3", "add-role4")));
}
@ -371,7 +373,7 @@ public class ClientApiV2Test {
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
OIDCClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertThat(updated.getRoles(), is(Set.of()));
}
}
@ -383,21 +385,19 @@ public class ClientApiV2Test {
setAuthHeader(createRequest);
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
ClientRepresentation rep = new ClientRepresentation();
OIDCClientRepresentation rep = new OIDCClientRepresentation();
rep.setClientId("sa-declarative-test");
rep.setEnabled(true);
var serviceAccount = new ClientRepresentation.ServiceAccount();
serviceAccount.setEnabled(true);
serviceAccount.setRoles(Set.of("default-roles-master", "offline_access"));
rep.setServiceAccount(serviceAccount);
rep.setLoginFlows(Set.of(OIDCClientRepresentation.Flow.SERVICE_ACCOUNT));
rep.setServiceAccountRoles(Set.of("default-roles-master", "offline_access"));
createRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(createRequest)) {
assertEquals(201, response.getStatusLine().getStatusCode());
ClientRepresentation created = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(created.getServiceAccount().getRoles(), is(Set.of("default-roles-master", "offline_access")));
OIDCClientRepresentation created = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertThat(created.getServiceAccountRoles(), is(Set.of("default-roles-master", "offline_access")));
}
// 2. Update with completely new roles - should remove old ones and add new ones
@ -405,63 +405,55 @@ public class ClientApiV2Test {
setAuthHeader(updateRequest);
updateRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
serviceAccount.setRoles(Set.of("uma_authorization", "offline_access"));
rep.setServiceAccount(serviceAccount);
rep.setServiceAccountRoles(Set.of("uma_authorization", "offline_access"));
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(updated.getServiceAccount().getRoles(), is(Set.of("uma_authorization", "offline_access")));
OIDCClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertThat(updated.getServiceAccountRoles(), is(Set.of("uma_authorization", "offline_access")));
}
// 3. Update with partial overlap - keep some, add some, remove some
serviceAccount.setRoles(Set.of("offline_access", "default-roles-master"));
rep.setServiceAccount(serviceAccount);
rep.setServiceAccountRoles(Set.of("offline_access", "default-roles-master"));
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(updated.getServiceAccount().getRoles(), is(Set.of("offline_access", "default-roles-master")));
OIDCClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertThat(updated.getServiceAccountRoles(), is(Set.of("offline_access", "default-roles-master")));
}
// 4. Update with same roles - should be idempotent
serviceAccount.setRoles(Set.of("offline_access", "default-roles-master"));
rep.setServiceAccount(serviceAccount);
rep.setServiceAccountRoles(Set.of("offline_access", "default-roles-master"));
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(updated.getServiceAccount().getRoles(), is(Set.of("offline_access", "default-roles-master")));
OIDCClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertThat(updated.getServiceAccountRoles(), is(Set.of("offline_access", "default-roles-master")));
}
// 5. Update with empty set - should remove all roles
serviceAccount.setRoles(Set.of());
rep.setServiceAccount(serviceAccount);
rep.setServiceAccountRoles(Set.of());
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
try (var response = client.execute(updateRequest)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertThat(updated.getServiceAccount().getRoles(), is(Set.of()));
OIDCClientRepresentation updated = mapper.createParser(response.getEntity().getContent()).readValueAs(OIDCClientRepresentation.class);
assertThat(updated.getServiceAccountRoles(), is(Set.of()));
}
}
private ClientRepresentation getTestingFullClientRep() {
var rep = new ClientRepresentation();
private OIDCClientRepresentation getTestingFullClientRep() {
var rep = new OIDCClientRepresentation();
rep.setClientId("my-client");
rep.setDisplayName("My Client");
rep.setDescription("This is My Client");
rep.setProtocol(ClientRepresentation.OIDC);
rep.setEnabled(true);
rep.setAppUrl("http://localhost:3000");
rep.setAppRedirectUrls(Set.of("http://localhost:3000", "http://localhost:3001"));
// no login flows -> only flow overrides map
// rep.setLoginFlows(Set.of("browser"));
var auth = new ClientRepresentation.Auth();
auth.setEnabled(true);
rep.setRedirectUris(Set.of("http://localhost:3000", "http://localhost:3001"));
var auth = new OIDCClientRepresentation.Auth();
auth.setMethod("client-jwt");
auth.setSecret("secret-1234");
// no certificate inside the old rep
@ -469,11 +461,9 @@ public class ClientApiV2Test {
rep.setAuth(auth);
rep.setWebOrigins(Set.of("http://localhost:4000", "http://localhost:4001"));
rep.setRoles(Set.of("view-consent", "manage-account"));
var serviceAccount = new ClientRepresentation.ServiceAccount();
serviceAccount.setEnabled(true);
rep.setLoginFlows(Set.of(OIDCClientRepresentation.Flow.SERVICE_ACCOUNT));
// TODO when roles are not set and SA is enabled, the default role 'default-roles-master' for the SA is used for the master realm
serviceAccount.setRoles(Set.of("default-roles-master"));
rep.setServiceAccount(serviceAccount);
rep.setServiceAccountRoles(Set.of("default-roles-master"));
// not implemented yet
// rep.setAdditionalFields(Map.of("key1", "val1", "key2", "val2"));
return rep;