From ed69f332af370cb9b4762f5611bd4c22364bff63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Muzik=C3=A1=C5=99?= Date: Tue, 6 Jan 2026 16:23:30 +0100 Subject: [PATCH] [admin-v2] Polymorphism, refined OIDC Client representation (#44727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [admin-v2] Polymorphism, refined OIDC Client representation Closes #43290 Signed-off-by: Václav Muzikář * Remove AbstractRepModelMapper Signed-off-by: Václav Muzikář --------- Signed-off-by: Václav Muzikář --- pom.xml | 7 - quarkus/runtime/pom.xml | 6 - .../quarkus/runtime/oas/OASModelFilter.java | 297 +++++++++++++----- .../src/main/resources/application.properties | 4 +- .../keycloak/it/cli/dist/OpenApiDistTest.java | 30 ++ rest/admin-v2/api/pom.xml | 11 - .../models/mapper/BaseClientModelMapper.java | 79 +++++ .../models/mapper/ClientModelMapper.java | 17 +- .../mapper/ClientModelMapperFactory.java | 9 + .../models/mapper/ClientModelMapperSpi.java | 30 ++ .../mapper/MapStructClientModelMapper.java | 103 ------ .../models/mapper/MapStructModelMapper.java | 16 - .../keycloak/models/mapper/ModelMapper.java | 7 - .../models/mapper/OIDCClientModelMapper.java | 92 ++++++ .../mapper/OIDCClientModelMapperFactory.java | 33 ++ .../models/mapper/RepModelMapper.java | 14 + .../admin/v2/BaseClientRepresentation.java | 131 ++++++++ .../admin/v2/BaseRepresentation.java | 2 + .../admin/v2/ClientRepresentation.java | 297 ------------------ .../admin/v2/OIDCClientRepresentation.java | 146 +++++++++ .../admin/v2/SAMLClientRepresentation.java | 16 + .../services/client/ClientService.java | 12 +- .../services/client/DefaultClientService.java | 120 +++---- ...oak.models.mapper.ClientModelMapperFactory | 1 + .../services/org.keycloak.provider.Spi | 1 + .../keycloak/admin/api/client/ClientApi.java | 10 +- .../keycloak/admin/api/client/ClientsApi.java | 8 +- .../admin/api/client/DefaultClientApi.java | 16 +- .../admin/api/client/DefaultClientsApi.java | 6 +- .../admin/client/v2/ClientApiV2Test.java | 104 +++--- 30 files changed, 952 insertions(+), 673 deletions(-) create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/BaseClientModelMapper.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapperFactory.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapperSpi.java delete mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/MapStructClientModelMapper.java delete mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/MapStructModelMapper.java delete mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapper.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/OIDCClientModelMapper.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/OIDCClientModelMapperFactory.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/RepModelMapper.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseClientRepresentation.java delete mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/ClientRepresentation.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/OIDCClientRepresentation.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/SAMLClientRepresentation.java create mode 100644 rest/admin-v2/api/src/main/resources/META-INF/services/org.keycloak.models.mapper.ClientModelMapperFactory create mode 100755 rest/admin-v2/api/src/main/resources/META-INF/services/org.keycloak.provider.Spi diff --git a/pom.xml b/pom.xml index 864a100f862..7d76a4c2a9f 100644 --- a/pom.xml +++ b/pom.xml @@ -226,8 +226,6 @@ 1.0.0.Alpha8 - 1.6.3 - true true @@ -399,11 +397,6 @@ xsom ${org.glassfish.jaxb.xsom.version} - - org.mapstruct - mapstruct - ${org.mapstruct.version} - org.bouncycastle diff --git a/quarkus/runtime/pom.xml b/quarkus/runtime/pom.xml index 2140087a5c9..54bfaa67657 100644 --- a/quarkus/runtime/pom.xml +++ b/quarkus/runtime/pom.xml @@ -139,12 +139,6 @@ rdf-urdna - - org.mapstruct - mapstruct - ${org.mapstruct.version} - - io.quarkus diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/oas/OASModelFilter.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/oas/OASModelFilter.java index 7f928164f1a..7b0ea62daab 100644 --- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/oas/OASModelFilter.java +++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/oas/OASModelFilter.java @@ -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 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 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 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 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 operationsOf(PathItem pi) { - List 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> 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> 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; + } } diff --git a/quarkus/runtime/src/main/resources/application.properties b/quarkus/runtime/src/main/resources/application.properties index 502672acff7..bc366823ca3 100644 --- a/quarkus/runtime/src/main/resources/application.properties +++ b/quarkus/runtime/src/main/resources/application.properties @@ -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 diff --git a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/OpenApiDistTest.java b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/OpenApiDistTest.java index 28287f9970f..38edc2a7442 100644 --- a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/OpenApiDistTest.java +++ b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/OpenApiDistTest.java @@ -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")); + } } diff --git a/rest/admin-v2/api/pom.xml b/rest/admin-v2/api/pom.xml index 46b1ad13bbd..2e5484ce817 100644 --- a/rest/admin-v2/api/pom.xml +++ b/rest/admin-v2/api/pom.xml @@ -45,10 +45,6 @@ jakarta.ws.rs jakarta.ws.rs-api - - org.mapstruct - mapstruct - jakarta.enterprise jakarta.enterprise.cdi-api @@ -64,13 +60,6 @@ -AgeneratedTranslationFilesPath=${project.build.directory}/generated-translation-files - - - org.mapstruct - mapstruct-processor - ${org.mapstruct.version} - - diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/BaseClientModelMapper.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/BaseClientModelMapper.java new file mode 100644 index 00000000000..7541efc518a --- /dev/null +++ b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/BaseClientModelMapper.java @@ -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 + */ +public abstract class BaseClientModelMapper 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() { + } +} diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapper.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapper.java index 63cc0bce8bd..54234581b4a 100644 --- a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapper.java +++ b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapper.java @@ -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 + */ +public interface ClientModelMapper extends Provider, RepModelMapper { } diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapperFactory.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapperFactory.java new file mode 100644 index 00000000000..612d8c003f5 --- /dev/null +++ b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapperFactory.java @@ -0,0 +1,9 @@ +package org.keycloak.models.mapper; + +import org.keycloak.provider.ProviderFactory; + +/** + * @author Vaclav Muzikar + */ +public interface ClientModelMapperFactory extends ProviderFactory { +} diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapperSpi.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapperSpi.java new file mode 100644 index 00000000000..3f1504ffbad --- /dev/null +++ b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapperSpi.java @@ -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 + */ +public class ClientModelMapperSpi implements Spi { + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return "client-model-mapper"; + } + + @Override + public Class getProviderClass() { + return ClientModelMapper.class; + } + + @Override + public Class> getProviderFactoryClass() { + return ClientModelMapperFactory.class; + } +} diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/MapStructClientModelMapper.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/MapStructClientModelMapper.java deleted file mode 100644 index 70535568bb0..00000000000 --- a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/MapStructClientModelMapper.java +++ /dev/null @@ -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 getRoleStrings(Stream stream) { - return stream.map(RoleModel::getName).collect(Collectors.toSet()); - } - - @Named("getServiceAccountRoles") - default Set getServiceAccountRoles(@Context KeycloakSession session, ClientModel client) { - if (client.isServiceAccountsEnabled()) { - return session.users().getServiceAccount(client) - .getRoleMappingsStream() - .map(RoleModel::getName) - .collect(Collectors.toSet()); - } - return Collections.emptySet(); - } -} diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/MapStructModelMapper.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/MapStructModelMapper.java deleted file mode 100644 index ea384f98563..00000000000 --- a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/MapStructModelMapper.java +++ /dev/null @@ -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; - } -} diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapper.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapper.java deleted file mode 100644 index 510b70b7787..00000000000 --- a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapper.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.keycloak.models.mapper; - -public interface ModelMapper { - - ClientModelMapper clients(); - -} diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/OIDCClientModelMapper.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/OIDCClientModelMapper.java new file mode 100644 index 00000000000..2ce88eb8701 --- /dev/null +++ b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/OIDCClientModelMapper.java @@ -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 + */ +public class OIDCClientModelMapper extends BaseClientModelMapper { + 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 createLoginFlows(ClientModel model) { + Set 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 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 getServiceAccountRoles(ClientModel client) { + if (client.isServiceAccountsEnabled()) { + return session.users().getServiceAccount(client) + .getRoleMappingsStream() + .map(RoleModel::getName) + .collect(Collectors.toSet()); + } + return Collections.emptySet(); + } +} diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/OIDCClientModelMapperFactory.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/OIDCClientModelMapperFactory.java new file mode 100644 index 00000000000..5b91e53ec93 --- /dev/null +++ b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/OIDCClientModelMapperFactory.java @@ -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 + */ +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() { + } +} diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/RepModelMapper.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/RepModelMapper.java new file mode 100644 index 00000000000..01130c4ecf9 --- /dev/null +++ b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/RepModelMapper.java @@ -0,0 +1,14 @@ +package org.keycloak.models.mapper; + +/** + * @author Vaclav Muzikar + */ +public interface RepModelMapper { + T fromModel(U model); + + default U toModel(T rep) { + return toModel(rep, null); + } + + U toModel(T rep, U existingModel); +} diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseClientRepresentation.java b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseClientRepresentation.java new file mode 100644 index 00000000000..b87d4967413 --- /dev/null +++ b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseClientRepresentation.java @@ -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 getRedirectUris() { + return redirectUris; + } + + public void setRedirectUris(Set redirectUris) { + this.redirectUris = redirectUris; + } + + public Set getRoles() { + return roles; + } + + public void setRoles(Set roles) { + this.roles = roles; + } + + @JsonIgnore + public abstract String getProtocol(); + + @JsonAnyGetter + public Map 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); + } +} diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseRepresentation.java b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseRepresentation.java index 83415699db0..86c90c89aec 100644 --- a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseRepresentation.java +++ b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseRepresentation.java @@ -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 diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/ClientRepresentation.java b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/ClientRepresentation.java deleted file mode 100644 index b992d2de29d..00000000000 --- a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/ClientRepresentation.java +++ /dev/null @@ -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(); - - @JsonInclude(JsonInclude.Include.NON_EMPTY) - @JsonPropertyDescription("Login flows that are enabled for this client") - private Set<@NotBlank String> 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 associated with this client") - private Set<@NotBlank String> roles = new LinkedHashSet(); - - @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 getAppRedirectUrls() { - return appRedirectUrls; - } - - public void setAppRedirectUrls(Set appRedirectUrls) { - this.appRedirectUrls = appRedirectUrls; - } - - public Set getLoginFlows() { - return loginFlows; - } - - public void setLoginFlows(Set loginFlows) { - this.loginFlows = loginFlows; - } - - public Auth getAuth() { - return auth; - } - - public void setAuth(Auth auth) { - this.auth = auth; - } - - public Set getWebOrigins() { - return webOrigins; - } - - public void setWebOrigins(Set webOrigins) { - this.webOrigins = webOrigins; - } - - public Set getRoles() { - return roles; - } - - public void setRoles(Set 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 roles = new LinkedHashSet(); - - public Boolean getEnabled() { - return enabled; - } - - public void setEnabled(Boolean enabled) { - this.enabled = enabled; - } - - public Set getRoles() { - return roles; - } - - public void setRoles(Set 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 - ); - } -} diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/OIDCClientRepresentation.java b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/OIDCClientRepresentation.java new file mode 100644 index 00000000000..98665c5eaf2 --- /dev/null +++ b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/OIDCClientRepresentation.java @@ -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 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 getLoginFlows() { + return loginFlows; + } + + public void setLoginFlows(Set loginFlows) { + this.loginFlows = loginFlows; + } + + public Auth getAuth() { + return auth; + } + + public void setAuth(Auth auth) { + this.auth = auth; + } + + public Set getWebOrigins() { + return webOrigins; + } + + public void setWebOrigins(Set webOrigins) { + this.webOrigins = webOrigins; + } + + public Set getServiceAccountRoles() { + return serviceAccountRoles; + } + + public void setServiceAccountRoles(Set 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); + } +} diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/SAMLClientRepresentation.java b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/SAMLClientRepresentation.java new file mode 100644 index 00000000000..c85732d1813 --- /dev/null +++ b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/SAMLClientRepresentation.java @@ -0,0 +1,16 @@ +package org.keycloak.representations.admin.v2; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +/** + * @author Vaclav Muzikar + */ +@Schema +public class SAMLClientRepresentation extends BaseClientRepresentation { + public static final String PROTOCOL = "saml"; + + @Override + public String getProtocol() { + return PROTOCOL; + } +} diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientService.java b/rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientService.java index ad4816fe5f8..c95dfa5153d 100644 --- a/rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientService.java +++ b/rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientService.java @@ -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 getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions); + Optional getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions); - Stream getClients(RealmModel realm, ClientProjectionOptions projectionOptions, ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions); + Stream getClients(RealmModel realm, ClientProjectionOptions projectionOptions, ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions); - Stream deleteClients(RealmModel realm, ClientSearchOptions searchOptions); + Stream 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; } diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/services/client/DefaultClientService.java b/rest/admin-v2/api/src/main/java/org/keycloak/services/client/DefaultClientService.java index 2018f1e8be6..cd7dd1e9af9 100644 --- a/rest/admin-v2/api/src/main/java/org/keycloak/services/client/DefaultClientService.java +++ b/rest/admin-v2/api/src/main/java/org/keycloak/services/client/DefaultClientService.java @@ -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 getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions) { + public Optional 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 getClients(RealmModel realm, ClientProjectionOptions projectionOptions, + public Stream 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 deleteClients(RealmModel realm, ClientSearchOptions searchOptions) { + public Stream deleteClients(RealmModel realm, ClientSearchOptions searchOptions) { // TODO Auto-generated method stub return null; } @@ -128,56 +136,58 @@ public class DefaultClientService implements ClientService { *

* 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 desiredRoleNames = Optional.ofNullable(serviceAccount.getRoles()).orElse(Collections.emptySet()); - Set currentRoles = serviceAccountUser.getRoleMappingsStream().collect(Collectors.toSet()); - Set 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 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 desiredRoleNames = Optional.ofNullable(rep.getServiceAccountRoles()).orElse(Collections.emptySet()); + Set currentRoles = serviceAccountUser.getRoleMappingsStream().collect(Collectors.toSet()); + Set currentRoleNames = currentRoles.stream().map(RoleModel::getName).collect(Collectors.toSet()); - // Get extra roles (in current but not in desired) - List 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 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 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); } } } diff --git a/rest/admin-v2/api/src/main/resources/META-INF/services/org.keycloak.models.mapper.ClientModelMapperFactory b/rest/admin-v2/api/src/main/resources/META-INF/services/org.keycloak.models.mapper.ClientModelMapperFactory new file mode 100644 index 00000000000..a8e6f488541 --- /dev/null +++ b/rest/admin-v2/api/src/main/resources/META-INF/services/org.keycloak.models.mapper.ClientModelMapperFactory @@ -0,0 +1 @@ +org.keycloak.models.mapper.OIDCClientModelMapperFactory \ No newline at end of file diff --git a/rest/admin-v2/api/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/rest/admin-v2/api/src/main/resources/META-INF/services/org.keycloak.provider.Spi new file mode 100755 index 00000000000..84d24dcaeab --- /dev/null +++ b/rest/admin-v2/api/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -0,0 +1 @@ +org.keycloak.models.mapper.ClientModelMapperSpi diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java index d1705bb5c7d..01bc82f9f3b 100644 --- a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java +++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java @@ -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) diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApi.java index 049250f7895..8cc8d2234bf 100644 --- a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApi.java +++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApi.java @@ -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 getClients(); + Stream 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); diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java index 4e2b49a5679..860d5c0b6bc 100644 --- a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java +++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java @@ -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); } } diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientsApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientsApi.java index d1769101fcc..c4f4da214bd 100644 --- a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientsApi.java +++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientsApi.java @@ -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 getClients() { + public Stream 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); diff --git a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java index ed1ba9c3850..bb3eaf185d5 100644 --- a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java +++ b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java @@ -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;