mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-07 14:02:04 -03:30
[admin-v2] Polymorphism, refined OIDC Client representation (#44727)
* [admin-v2] Polymorphism, refined OIDC Client representation Closes #43290 Signed-off-by: Václav Muzikář <vmuzikar@redhat.com> * Remove AbstractRepModelMapper Signed-off-by: Václav Muzikář <vmuzikar@redhat.com> --------- Signed-off-by: Václav Muzikář <vmuzikar@redhat.com>
This commit is contained in:
parent
ffed84194e
commit
ed69f332af
7
pom.xml
7
pom.xml
@ -226,8 +226,6 @@
|
||||
<!-- Used to test SAML Galleon feature-pack layers discovery -->
|
||||
<version.org.wildfly.glow>1.0.0.Alpha8</version.org.wildfly.glow>
|
||||
|
||||
<org.mapstruct.version>1.6.3</org.mapstruct.version>
|
||||
|
||||
<!-- Galleon -->
|
||||
<galleon.fork.embedded>true</galleon.fork.embedded>
|
||||
<galleon.log.time>true</galleon.log.time>
|
||||
@ -399,11 +397,6 @@
|
||||
<artifactId>xsom</artifactId>
|
||||
<version>${org.glassfish.jaxb.xsom.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct</artifactId>
|
||||
<version>${org.mapstruct.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
|
||||
@ -139,12 +139,6 @@
|
||||
<artifactId>rdf-urdna</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct</artifactId>
|
||||
<version>${org.mapstruct.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Hibernate validator -->
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
|
||||
@ -1,99 +1,246 @@
|
||||
package org.keycloak.quarkus.runtime.oas;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import io.quarkus.smallrye.openapi.OpenApiFilter;
|
||||
import org.eclipse.microprofile.openapi.OASFactory;
|
||||
import org.eclipse.microprofile.openapi.OASFilter;
|
||||
import org.eclipse.microprofile.openapi.models.OpenAPI;
|
||||
import org.eclipse.microprofile.openapi.models.Operation;
|
||||
import org.eclipse.microprofile.openapi.models.PathItem;
|
||||
import org.eclipse.microprofile.openapi.models.media.Content;
|
||||
import org.eclipse.microprofile.openapi.models.media.Discriminator;
|
||||
import org.eclipse.microprofile.openapi.models.media.Schema;
|
||||
import org.eclipse.microprofile.openapi.models.parameters.RequestBody;
|
||||
import org.eclipse.microprofile.openapi.models.responses.APIResponses;
|
||||
import org.jboss.jandex.AnnotationInstance;
|
||||
import org.jboss.jandex.AnnotationValue;
|
||||
import org.jboss.jandex.ClassInfo;
|
||||
import org.jboss.jandex.IndexView;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
@OpenApiFilter(OpenApiFilter.RunStage.BUILD)
|
||||
public class OASModelFilter implements OASFilter {
|
||||
|
||||
@Override
|
||||
public void filterOpenAPI(OpenAPI openAPI) {
|
||||
// Filter Paths that have the '/admin/api/v2' prefix
|
||||
Map<String, PathItem> newPaths = openAPI.getPaths().getPathItems().entrySet().stream()
|
||||
.filter(entry -> entry.getKey().startsWith("/admin/api/v2"))
|
||||
.collect(Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
entry -> sortOperationsByMethod(entry.getValue())
|
||||
));
|
||||
private final IndexView index;
|
||||
private final Logger log = Logger.getLogger(OASModelFilter.class);
|
||||
private final Map<String, ClassInfo> simpleNameToClassInfoMap = new HashMap<>();
|
||||
|
||||
// Replace ALL Paths with filtered Paths
|
||||
var paths = OASFactory.createPaths();
|
||||
newPaths.forEach(paths::addPathItem);
|
||||
openAPI.setPaths(paths);
|
||||
public static final String REF_PREFIX = "#/components/schemas/";
|
||||
|
||||
// Compute tags that are actually used by remaining operations
|
||||
Set<String> usedTags = newPaths.values().stream()
|
||||
.flatMap(pi -> operationsOf(pi).stream())
|
||||
.flatMap(op -> Optional.ofNullable(op.getTags()).orElseGet(List::of).stream())
|
||||
.collect(Collectors.toSet());
|
||||
public OASModelFilter(IndexView indexView) {
|
||||
this.index = indexView;
|
||||
log.debug("Index size: " + indexView.getKnownClasses().size());
|
||||
|
||||
// Drop top-level tags not used anywhere
|
||||
if (openAPI.getTags() != null) {
|
||||
var filteredTags = openAPI.getTags().stream()
|
||||
.filter(t -> t.getName() != null && usedTags.contains(t.getName()))
|
||||
.collect(Collectors.toList());
|
||||
openAPI.setTags(filteredTags.isEmpty() ? null : filteredTags);
|
||||
}
|
||||
}
|
||||
|
||||
private PathItem sortOperationsByMethod(PathItem pathItem) {
|
||||
PathItem sortedPathItem = OASFactory.createPathItem();
|
||||
|
||||
// Add operations order: GET -> POST -> PUT -> PATCH -> DELETE -> HEAD -> OPTIONS -> TRACE
|
||||
if (pathItem.getGET() != null) {
|
||||
sortedPathItem.setGET(pathItem.getGET());
|
||||
}
|
||||
if (pathItem.getPOST() != null) {
|
||||
sortedPathItem.setPOST(pathItem.getPOST());
|
||||
}
|
||||
if (pathItem.getPUT() != null) {
|
||||
sortedPathItem.setPUT(pathItem.getPUT());
|
||||
}
|
||||
if (pathItem.getPATCH() != null) {
|
||||
sortedPathItem.setPATCH(pathItem.getPATCH());
|
||||
}
|
||||
if (pathItem.getDELETE() != null) {
|
||||
sortedPathItem.setDELETE(pathItem.getDELETE());
|
||||
}
|
||||
if (pathItem.getHEAD() != null) {
|
||||
sortedPathItem.setHEAD(pathItem.getHEAD());
|
||||
}
|
||||
if (pathItem.getOPTIONS() != null) {
|
||||
sortedPathItem.setOPTIONS(pathItem.getOPTIONS());
|
||||
}
|
||||
if (pathItem.getTRACE() != null) {
|
||||
sortedPathItem.setTRACE(pathItem.getTRACE());
|
||||
indexView.getKnownClasses().forEach(classInfo -> {
|
||||
simpleNameToClassInfoMap.put(classInfo.simpleName(), classInfo);
|
||||
});
|
||||
}
|
||||
|
||||
sortedPathItem.setSummary(pathItem.getSummary());
|
||||
sortedPathItem.setDescription(pathItem.getDescription());
|
||||
sortedPathItem.setServers(pathItem.getServers());
|
||||
sortedPathItem.setParameters(pathItem.getParameters());
|
||||
@Override
|
||||
public void filterOpenAPI(OpenAPI openAPI) {
|
||||
// Sort Paths
|
||||
Map<String, PathItem> newPaths = openAPI.getPaths().getPathItems().entrySet().stream()
|
||||
.collect(Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
entry -> sortOperationsByMethod(entry.getValue())
|
||||
));
|
||||
|
||||
return sortedPathItem;
|
||||
}
|
||||
// Replace ALL Paths with sorted Paths
|
||||
var paths = OASFactory.createPaths();
|
||||
newPaths.forEach(paths::addPathItem);
|
||||
openAPI.setPaths(paths);
|
||||
|
||||
private List<Operation> operationsOf(PathItem pi) {
|
||||
List<Operation> ops = new ArrayList<>(8);
|
||||
if (pi.getGET() != null) ops.add(pi.getGET());
|
||||
if (pi.getPOST() != null) ops.add(pi.getPOST());
|
||||
if (pi.getPUT() != null) ops.add(pi.getPUT());
|
||||
if (pi.getPATCH() != null) ops.add(pi.getPATCH());
|
||||
if (pi.getDELETE() != null) ops.add(pi.getDELETE());
|
||||
if (pi.getHEAD() != null) ops.add(pi.getHEAD());
|
||||
if (pi.getOPTIONS() != null) ops.add(pi.getOPTIONS());
|
||||
if (pi.getTRACE() != null) ops.add(pi.getTRACE());
|
||||
return ops;
|
||||
}
|
||||
Map<String, Set<Schema>> discriminatorPropertiesToBeAdded = new HashMap<>();
|
||||
|
||||
// Reflect Jackson annotations in OpenAPI spec
|
||||
// Follows https://swagger.io/docs/specification/v3_0/data-models/inheritance-and-polymorphism/
|
||||
openAPI.getPaths().getPathItems().values().stream()
|
||||
.flatMap(p -> p.getOperations().values().stream())
|
||||
.forEach(operation -> {
|
||||
// This is not nice but so is the model structure...
|
||||
|
||||
// Request body
|
||||
Optional.ofNullable(operation.getRequestBody())
|
||||
.map(RequestBody::getContent)
|
||||
.map(Content::getMediaTypes)
|
||||
.map(Map::values)
|
||||
.map(Collection::stream)
|
||||
.ifPresent(mediaTypes -> {
|
||||
mediaTypes.forEach(mediaType -> {
|
||||
mediaType.setSchema(replaceSchemaWithChildrenIfNeeded(mediaType.getSchema(), openAPI, discriminatorPropertiesToBeAdded));
|
||||
});
|
||||
});
|
||||
|
||||
// Responses
|
||||
Optional.ofNullable(operation.getResponses())
|
||||
.map(APIResponses::getAPIResponses)
|
||||
.map(Map::values)
|
||||
.map(Collection::stream)
|
||||
.ifPresent(apiResponses -> {
|
||||
apiResponses.forEach(apiResponse -> {
|
||||
Optional.ofNullable(apiResponse.getContent())
|
||||
.map(Content::getMediaTypes)
|
||||
.map(Map::values)
|
||||
.map(Collection::stream)
|
||||
.ifPresent(mediaTypes -> {
|
||||
mediaTypes.forEach(mediaType -> {
|
||||
mediaType.setSchema(replaceSchemaWithChildrenIfNeeded(mediaType.getSchema(), openAPI, discriminatorPropertiesToBeAdded));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add missing discriminator properties to subclass schemas
|
||||
// Normally, this is handled by Jackson
|
||||
discriminatorPropertiesToBeAdded.forEach((propertyName, schemas) -> {
|
||||
schemas.forEach(schema -> {
|
||||
if (schema.getProperties() == null || !schema.getProperties().containsKey(propertyName)) {
|
||||
Schema discriminatorPropertySchema = OASFactory.createSchema().addType(Schema.SchemaType.STRING);
|
||||
schema.addProperty(propertyName, discriminatorPropertySchema);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private PathItem sortOperationsByMethod(PathItem pathItem) {
|
||||
PathItem sortedPathItem = OASFactory.createPathItem();
|
||||
|
||||
// Add operations order: GET -> POST -> PUT -> PATCH -> DELETE -> HEAD -> OPTIONS -> TRACE
|
||||
if (pathItem.getGET() != null) {
|
||||
sortedPathItem.setGET(pathItem.getGET());
|
||||
}
|
||||
if (pathItem.getPOST() != null) {
|
||||
sortedPathItem.setPOST(pathItem.getPOST());
|
||||
}
|
||||
if (pathItem.getPUT() != null) {
|
||||
sortedPathItem.setPUT(pathItem.getPUT());
|
||||
}
|
||||
if (pathItem.getPATCH() != null) {
|
||||
sortedPathItem.setPATCH(pathItem.getPATCH());
|
||||
}
|
||||
if (pathItem.getDELETE() != null) {
|
||||
sortedPathItem.setDELETE(pathItem.getDELETE());
|
||||
}
|
||||
if (pathItem.getHEAD() != null) {
|
||||
sortedPathItem.setHEAD(pathItem.getHEAD());
|
||||
}
|
||||
if (pathItem.getOPTIONS() != null) {
|
||||
sortedPathItem.setOPTIONS(pathItem.getOPTIONS());
|
||||
}
|
||||
if (pathItem.getTRACE() != null) {
|
||||
sortedPathItem.setTRACE(pathItem.getTRACE());
|
||||
}
|
||||
|
||||
sortedPathItem.setSummary(pathItem.getSummary());
|
||||
sortedPathItem.setDescription(pathItem.getDescription());
|
||||
sortedPathItem.setServers(pathItem.getServers());
|
||||
sortedPathItem.setParameters(pathItem.getParameters());
|
||||
|
||||
return sortedPathItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the given schema with a new schema that uses oneOf to reference all subclasses if the original schema
|
||||
* has a $ref and the referenced class has Jackson @JsonTypeInfo and @JsonSubTypes annotations. I.e. adds polymorphism
|
||||
* support to OpenAPI generation.
|
||||
*
|
||||
* @param originalSchema
|
||||
* @param openAPI
|
||||
* @param discriminatorPropertiesToBeAdded
|
||||
* @return the new schema or the original schema if no changes were made
|
||||
*/
|
||||
private Schema replaceSchemaWithChildrenIfNeeded(Schema originalSchema, OpenAPI openAPI, Map<String, Set<Schema>> discriminatorPropertiesToBeAdded) {
|
||||
Schema arraySchema = null;
|
||||
if (originalSchema.getType() != null && originalSchema.getType().size() == 1 && Schema.SchemaType.ARRAY.equals(originalSchema.getType().get(0))) {
|
||||
arraySchema = originalSchema;
|
||||
originalSchema = originalSchema.getItems();
|
||||
}
|
||||
|
||||
if (originalSchema == null || originalSchema.getRef() == null) {
|
||||
return originalSchema;
|
||||
}
|
||||
|
||||
String parentSchemaName = originalSchema.getRef().substring(REF_PREFIX.length());
|
||||
|
||||
ClassInfo parentClassInfo = simpleNameToClassInfoMap.get(parentSchemaName);
|
||||
if (parentClassInfo == null) {
|
||||
throw new IllegalStateException("Could not find class in index for schema: " + parentSchemaName);
|
||||
}
|
||||
|
||||
AnnotationInstance typeInfoAnnotation = parentClassInfo.annotation(JsonTypeInfo.class);
|
||||
AnnotationInstance subTypesAnnotation = parentClassInfo.annotation(JsonSubTypes.class);
|
||||
if (typeInfoAnnotation == null || subTypesAnnotation == null) {
|
||||
log.debugf("Class %s does not have JsonTypeInfo or JsonSubTypes annotations, skipping", parentClassInfo.simpleName());
|
||||
return originalSchema;
|
||||
}
|
||||
|
||||
AnnotationInstance[] typeAnnotations = Optional.of(subTypesAnnotation.value()).map(AnnotationValue::asNestedArray).orElse(new AnnotationInstance[0]);
|
||||
if (typeAnnotations.length == 0) {
|
||||
log.debugf("Class %s does not have any JsonSubTypes defined, skipping", parentClassInfo.simpleName());
|
||||
return originalSchema;
|
||||
}
|
||||
|
||||
// Validations
|
||||
|
||||
AnnotationValue useValue = typeInfoAnnotation.value("use");
|
||||
if (useValue == null || !JsonTypeInfo.Id.SIMPLE_NAME.name().equals(useValue.asEnum())) {
|
||||
throw new IllegalArgumentException(parentClassInfo.simpleName() + ": JsonTypeInfo annotation must have use=SIMPLE_NAME.");
|
||||
}
|
||||
|
||||
AnnotationValue includeValue = typeInfoAnnotation.value("include");
|
||||
if (includeValue != null && !JsonTypeInfo.As.PROPERTY.name().equals(includeValue.asEnum())) {
|
||||
throw new IllegalArgumentException(parentClassInfo.simpleName() + ": JsonTypeInfo annotation must have include=PROPERTY, or include must not be set.");
|
||||
}
|
||||
|
||||
String discriminatorPropertyName = Optional.of(typeInfoAnnotation.value("property")).map(AnnotationValue::asString).orElse("");
|
||||
if (discriminatorPropertyName.isEmpty()) {
|
||||
throw new IllegalArgumentException(parentClassInfo.simpleName() + ": JsonTypeInfo annotation must have property set.");
|
||||
}
|
||||
|
||||
Schema newSchema = OASFactory.createSchema();
|
||||
|
||||
// Add discriminator
|
||||
|
||||
Discriminator discriminator = OASFactory.createDiscriminator().propertyName(discriminatorPropertyName);
|
||||
newSchema.setDiscriminator(discriminator);
|
||||
|
||||
// Create new schema with oneOf for each subclass
|
||||
|
||||
for (AnnotationInstance typeAnnotation : typeAnnotations) {
|
||||
String simpleSubClassName = typeAnnotation.value("value").asClass().name().withoutPackagePrefix();
|
||||
|
||||
// Add schema ref as oneOf to the new schema
|
||||
Schema subSchema = openAPI.getComponents().getSchemas().get(simpleSubClassName); // This won't work with inner classes due to '$' in the name
|
||||
if (subSchema == null) {
|
||||
throw new IllegalStateException(parentClassInfo.simpleName() + ": Could not find schema for subclass: " + simpleSubClassName + ". Make sure the subclass has the @Schema annotation.");
|
||||
}
|
||||
String ref = REF_PREFIX + simpleSubClassName;
|
||||
Schema schemaRef = OASFactory.createSchema().ref(ref);
|
||||
newSchema.addOneOf(schemaRef);
|
||||
|
||||
// Add mapping to discriminator
|
||||
String typeName = Optional.of(typeAnnotation.value("name")).map(AnnotationValue::asString).orElse("");
|
||||
if (!typeName.isEmpty()) {
|
||||
discriminator.addMapping(typeName, ref);
|
||||
}
|
||||
|
||||
discriminatorPropertiesToBeAdded.computeIfAbsent(discriminatorPropertyName, k -> new HashSet<>()).add(subSchema);
|
||||
}
|
||||
|
||||
if (arraySchema != null) {
|
||||
arraySchema.setItems(newSchema);
|
||||
newSchema = arraySchema;
|
||||
}
|
||||
|
||||
return newSchema;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,10 +45,6 @@
|
||||
<groupId>jakarta.ws.rs</groupId>
|
||||
<artifactId>jakarta.ws.rs-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.enterprise</groupId>
|
||||
<artifactId>jakarta.enterprise.cdi-api</artifactId>
|
||||
@ -64,13 +60,6 @@
|
||||
<compilerArgument>
|
||||
-AgeneratedTranslationFilesPath=${project.build.directory}/generated-translation-files
|
||||
</compilerArgument>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct-processor</artifactId>
|
||||
<version>${org.mapstruct.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.RoleModel;
|
||||
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
|
||||
|
||||
/**
|
||||
* @author Vaclav Muzikar <vmuzikar@redhat.com>
|
||||
*/
|
||||
public abstract class BaseClientModelMapper<T extends BaseClientRepresentation> implements ClientModelMapper {
|
||||
protected final KeycloakSession session;
|
||||
|
||||
public BaseClientModelMapper(KeycloakSession session) {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BaseClientRepresentation fromModel(ClientModel model) {
|
||||
// We don't want reps to depend on any unnecessary fields deps, hence no generated builder.
|
||||
|
||||
T rep = createClientRepresentation();
|
||||
|
||||
rep.setEnabled(model.isEnabled());
|
||||
rep.setClientId(model.getClientId());
|
||||
rep.setDescription(model.getDescription());
|
||||
rep.setDisplayName(model.getName());
|
||||
rep.setAppUrl(model.getBaseUrl());
|
||||
rep.setRedirectUris(new HashSet<>(model.getRedirectUris()));
|
||||
rep.setRoles(model.getRolesStream().map(RoleModel::getName).collect(Collectors.toSet()));
|
||||
|
||||
fromModelSpecific(model, rep);
|
||||
|
||||
return rep;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public ClientModel toModel(BaseClientRepresentation rep, ClientModel existingModel) {
|
||||
if (existingModel == null) {
|
||||
existingModel = createClientModel(rep);
|
||||
}
|
||||
|
||||
existingModel.setEnabled(Boolean.TRUE.equals(rep.getEnabled()));
|
||||
existingModel.setClientId(rep.getClientId());
|
||||
existingModel.setDescription(rep.getDescription());
|
||||
existingModel.setName(rep.getDisplayName());
|
||||
existingModel.setBaseUrl(rep.getAppUrl());
|
||||
existingModel.setRedirectUris(new HashSet<>(rep.getRedirectUris()));
|
||||
// Roles are not handled here
|
||||
|
||||
toModelSpecific((T) rep, existingModel);
|
||||
|
||||
return existingModel;
|
||||
}
|
||||
|
||||
protected ClientModel createClientModel(BaseClientRepresentation rep) {
|
||||
RealmModel realm = session.getContext().getRealm();
|
||||
|
||||
// dummy add/remove to obtain a detached model
|
||||
var model = realm.addClient(rep.getClientId());
|
||||
realm.removeClient(model.getId());
|
||||
return model;
|
||||
}
|
||||
|
||||
protected abstract T createClientRepresentation();
|
||||
|
||||
protected abstract void fromModelSpecific(ClientModel model, T rep);
|
||||
|
||||
protected abstract void toModelSpecific(T rep, ClientModel model);
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,11 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
import org.keycloak.services.ServiceException;
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
|
||||
|
||||
public interface ClientModelMapper {
|
||||
|
||||
ClientRepresentation fromModel(KeycloakSession session, ClientModel model);
|
||||
|
||||
ClientModel toModel(KeycloakSession session, RealmModel realm, ClientModel existingModel, ClientRepresentation rep) throws ServiceException;
|
||||
|
||||
ClientModel toModel(KeycloakSession session, RealmModel realm, ClientRepresentation rep) throws ServiceException;
|
||||
/**
|
||||
* @author Vaclav Muzikar <vmuzikar@redhat.com>
|
||||
*/
|
||||
public interface ClientModelMapper extends Provider, RepModelMapper<BaseClientRepresentation, ClientModel> {
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
/**
|
||||
* @author Vaclav Muzikar <vmuzikar@redhat.com>
|
||||
*/
|
||||
public interface ClientModelMapperFactory extends ProviderFactory<ClientModelMapper> {
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.provider.Spi;
|
||||
|
||||
/**
|
||||
* @author Vaclav Muzikar <vmuzikar@redhat.com>
|
||||
*/
|
||||
public class ClientModelMapperSpi implements Spi {
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "client-model-mapper";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return ClientModelMapper.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory<ClientModelMapper>> getProviderFactoryClass() {
|
||||
return ClientModelMapperFactory.class;
|
||||
}
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.RoleModel;
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
import org.keycloak.services.ServiceException;
|
||||
|
||||
import org.mapstruct.Context;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.MappingTarget;
|
||||
import org.mapstruct.Named;
|
||||
import org.mapstruct.ObjectFactory;
|
||||
|
||||
@Mapper
|
||||
public interface MapStructClientModelMapper extends ClientModelMapper {
|
||||
|
||||
@Override
|
||||
@ModelToRep
|
||||
ClientRepresentation fromModel(@Context KeycloakSession session, ClientModel model);
|
||||
|
||||
// we don't want to ignore nulls so that we completely overwrite the state
|
||||
@Override
|
||||
@RepToModel
|
||||
ClientModel toModel(@Context KeycloakSession session, @Context RealmModel realm, @MappingTarget ClientModel existingModel, ClientRepresentation rep) throws ServiceException;
|
||||
|
||||
@Override
|
||||
@RepToModel
|
||||
ClientModel toModel(@Context KeycloakSession session, @Context RealmModel realm, ClientRepresentation rep) throws ServiceException;
|
||||
|
||||
/*-------------------------------------*
|
||||
* MAPPERS *
|
||||
*-------------------------------------*/
|
||||
@Mapping(target = "name", source = "displayName")
|
||||
@Mapping(target = "baseUrl", source = "appUrl")
|
||||
@Mapping(target = "redirectUris", source = "appRedirectUrls")
|
||||
@Mapping(target = "authenticationFlowBindingOverrides", source = "loginFlows", ignore = true) // TODO
|
||||
@Mapping(target = "publicClient", source = "auth.enabled", qualifiedByName = "isPublicClientPrimitive")
|
||||
@Mapping(target = "clientAuthenticatorType", source = "auth.method")
|
||||
@Mapping(target = "secret", source = "auth.secret")
|
||||
@Mapping(target = "serviceAccountsEnabled", source = "serviceAccount.enabled")
|
||||
@interface RepToModel {
|
||||
}
|
||||
|
||||
@Mapping(target = "displayName", source = "name")
|
||||
@Mapping(target = "appUrl", source = "baseUrl")
|
||||
@Mapping(target = "appRedirectUrls", source = "redirectUris")
|
||||
@Mapping(target = "loginFlows", source = "authenticationFlowBindingOverrides", ignore = true)
|
||||
@Mapping(target = "auth.enabled", source = "publicClient", qualifiedByName = "isPublicClient")
|
||||
@Mapping(target = "auth.method", source = "clientAuthenticatorType")
|
||||
@Mapping(target = "auth.secret", source = "secret")
|
||||
@Mapping(target = "auth.certificate", ignore = true) // no cert in the representation
|
||||
@Mapping(target = "roles", source = "rolesStream", qualifiedByName = "getRoleStrings")
|
||||
@Mapping(target = "serviceAccount.enabled", source = "serviceAccountsEnabled")
|
||||
@Mapping(target = "serviceAccount.roles", source = ".", qualifiedByName = "getServiceAccountRoles")
|
||||
@interface ModelToRep {
|
||||
}
|
||||
|
||||
/*-------------------------------------*
|
||||
* HELPER METHODS *
|
||||
*-------------------------------------*/
|
||||
@ObjectFactory
|
||||
default ClientModel createClientModel(@Context RealmModel realm, ClientRepresentation rep) {
|
||||
// dummy add/remove to obtain a detached model
|
||||
var model = realm.addClient(rep.getClientId());
|
||||
realm.removeClient(model.getId());
|
||||
return model;
|
||||
}
|
||||
|
||||
@Named("isPublicClientPrimitive")
|
||||
default boolean isPublicClientPrimitive(Boolean authEnabled) {
|
||||
var result = isPublicClient(authEnabled);
|
||||
return result != null ? result : false;
|
||||
}
|
||||
|
||||
@Named("isPublicClient")
|
||||
default Boolean isPublicClient(Boolean authEnabled) {
|
||||
return authEnabled != null ? !authEnabled : null;
|
||||
}
|
||||
|
||||
@Named("getRoleStrings")
|
||||
default Set<String> getRoleStrings(Stream<RoleModel> stream) {
|
||||
return stream.map(RoleModel::getName).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Named("getServiceAccountRoles")
|
||||
default Set<String> getServiceAccountRoles(@Context KeycloakSession session, ClientModel client) {
|
||||
if (client.isServiceAccountsEnabled()) {
|
||||
return session.users().getServiceAccount(client)
|
||||
.getRoleMappingsStream()
|
||||
.map(RoleModel::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
public interface ModelMapper {
|
||||
|
||||
ClientModelMapper clients();
|
||||
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RoleModel;
|
||||
import org.keycloak.representations.admin.v2.OIDCClientRepresentation;
|
||||
|
||||
/**
|
||||
* @author Vaclav Muzikar <vmuzikar@redhat.com>
|
||||
*/
|
||||
public class OIDCClientModelMapper extends BaseClientModelMapper<OIDCClientRepresentation> {
|
||||
public OIDCClientModelMapper(KeycloakSession session) {
|
||||
super(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected OIDCClientRepresentation createClientRepresentation() {
|
||||
return new OIDCClientRepresentation();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void fromModelSpecific(ClientModel model, OIDCClientRepresentation rep) {
|
||||
rep.setLoginFlows(createLoginFlows(model));
|
||||
|
||||
if (!model.isPublicClient()) {
|
||||
OIDCClientRepresentation.Auth auth = new OIDCClientRepresentation.Auth();
|
||||
auth.setMethod(model.getClientAuthenticatorType());
|
||||
auth.setSecret(model.getSecret());
|
||||
rep.setAuth(auth);
|
||||
// TODO: auth.certificate
|
||||
}
|
||||
|
||||
rep.setWebOrigins(new HashSet<>(model.getWebOrigins()));
|
||||
rep.setServiceAccountRoles(getServiceAccountRoles(model));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void toModelSpecific(OIDCClientRepresentation rep, ClientModel model) {
|
||||
if (rep.getAuth() != null) {
|
||||
model.setPublicClient(false);
|
||||
model.setClientAuthenticatorType(rep.getAuth().getMethod());
|
||||
model.setSecret(rep.getAuth().getSecret());
|
||||
} else {
|
||||
model.setPublicClient(true);
|
||||
}
|
||||
|
||||
setModelFromFlows(rep.getLoginFlows(), model);
|
||||
|
||||
model.setWebOrigins(new HashSet<>(rep.getWebOrigins()));
|
||||
|
||||
// Service account roles are not handled here
|
||||
}
|
||||
|
||||
private Set<OIDCClientRepresentation.Flow> createLoginFlows(ClientModel model) {
|
||||
Set<OIDCClientRepresentation.Flow> flows = new HashSet<>();
|
||||
if (model.isStandardFlowEnabled()) {
|
||||
flows.add(OIDCClientRepresentation.Flow.STANDARD);
|
||||
}
|
||||
if (model.isImplicitFlowEnabled()) {
|
||||
flows.add(OIDCClientRepresentation.Flow.IMPLICIT);
|
||||
}
|
||||
if (model.isDirectAccessGrantsEnabled()) {
|
||||
flows.add(OIDCClientRepresentation.Flow.DIRECT_GRANT);
|
||||
}
|
||||
// TODO: device flow, token exchange, ciba
|
||||
if (model.isServiceAccountsEnabled()) {
|
||||
flows.add(OIDCClientRepresentation.Flow.SERVICE_ACCOUNT);
|
||||
}
|
||||
return flows;
|
||||
}
|
||||
|
||||
private void setModelFromFlows(Set<OIDCClientRepresentation.Flow> flows, ClientModel model) {
|
||||
model.setStandardFlowEnabled(flows.contains(OIDCClientRepresentation.Flow.STANDARD));
|
||||
model.setImplicitFlowEnabled(flows.contains(OIDCClientRepresentation.Flow.IMPLICIT));
|
||||
model.setDirectAccessGrantsEnabled(flows.contains(OIDCClientRepresentation.Flow.DIRECT_GRANT));
|
||||
}
|
||||
|
||||
private Set<String> getServiceAccountRoles(ClientModel client) {
|
||||
if (client.isServiceAccountsEnabled()) {
|
||||
return session.users().getServiceAccount(client)
|
||||
.getRoleMappingsStream()
|
||||
.map(RoleModel::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.representations.admin.v2.OIDCClientRepresentation;
|
||||
|
||||
/**
|
||||
* @author Vaclav Muzikar <vmuzikar@redhat.com>
|
||||
*/
|
||||
public class OIDCClientModelMapperFactory implements ClientModelMapperFactory {
|
||||
@Override
|
||||
public ClientModelMapper create(KeycloakSession session) {
|
||||
return new OIDCClientModelMapper(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return OIDCClientRepresentation.PROTOCOL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Config.Scope config) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
/**
|
||||
* @author Vaclav Muzikar <vmuzikar@redhat.com>
|
||||
*/
|
||||
public interface RepModelMapper <T, U> {
|
||||
T fromModel(U model);
|
||||
|
||||
default U toModel(T rep) {
|
||||
return toModel(rep, null);
|
||||
}
|
||||
|
||||
U toModel(T rep, U existingModel);
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
package org.keycloak.representations.admin.v2;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import org.keycloak.representations.admin.v2.validation.CreateClient;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAnyGetter;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import org.hibernate.validator.constraints.URL;
|
||||
|
||||
@JsonTypeInfo(
|
||||
use = JsonTypeInfo.Id.SIMPLE_NAME,
|
||||
include = JsonTypeInfo.As.PROPERTY,
|
||||
property = BaseClientRepresentation.DISCRIMINATOR_FIELD
|
||||
)
|
||||
@JsonSubTypes({
|
||||
@JsonSubTypes.Type(value = OIDCClientRepresentation.class, name = "openid-connect"),
|
||||
@JsonSubTypes.Type(value = SAMLClientRepresentation.class, name = "saml")
|
||||
})
|
||||
public abstract class BaseClientRepresentation extends BaseRepresentation {
|
||||
public static final String DISCRIMINATOR_FIELD = "protocol";
|
||||
|
||||
@NotBlank(groups = CreateClient.class)
|
||||
@JsonPropertyDescription("ID uniquely identifying this client")
|
||||
protected String clientId;
|
||||
|
||||
@JsonPropertyDescription("Human readable name of the client")
|
||||
private String displayName;
|
||||
|
||||
@JsonPropertyDescription("Human readable description of the client")
|
||||
private String description;
|
||||
|
||||
@JsonPropertyDescription("Whether this client is enabled")
|
||||
private Boolean enabled;
|
||||
|
||||
@URL
|
||||
@JsonPropertyDescription("URL to the application's homepage that is represented by this client")
|
||||
private String appUrl;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@JsonPropertyDescription("URIs that the browser can redirect to after login")
|
||||
private Set<@NotBlank @URL(message = "Each redirect URL must be valid") String> redirectUris = new LinkedHashSet<>();
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@JsonPropertyDescription("Roles associated with this client")
|
||||
private Set<@NotBlank String> roles = new LinkedHashSet<>();
|
||||
|
||||
public String getClientId() {
|
||||
return clientId;
|
||||
}
|
||||
|
||||
public void setClientId(String clientId) {
|
||||
this.clientId = clientId;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Boolean getEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getAppUrl() {
|
||||
return appUrl;
|
||||
}
|
||||
|
||||
public void setAppUrl(String appUrl) {
|
||||
this.appUrl = appUrl;
|
||||
}
|
||||
|
||||
public Set<String> getRedirectUris() {
|
||||
return redirectUris;
|
||||
}
|
||||
|
||||
public void setRedirectUris(Set<String> redirectUris) {
|
||||
this.redirectUris = redirectUris;
|
||||
}
|
||||
|
||||
public Set<String> getRoles() {
|
||||
return roles;
|
||||
}
|
||||
|
||||
public void setRoles(Set<String> roles) {
|
||||
this.roles = roles;
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public abstract String getProtocol();
|
||||
|
||||
@JsonAnyGetter
|
||||
public Map<String, Object> getAdditionalFields() {
|
||||
return additionalFields;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof BaseClientRepresentation that)) return false;
|
||||
return Objects.equals(clientId, that.clientId) && Objects.equals(displayName, that.displayName) && Objects.equals(description, that.description) && Objects.equals(enabled, that.enabled) && Objects.equals(appUrl, that.appUrl) && Objects.equals(redirectUris, that.redirectUris) && Objects.equals(roles, that.roles) && Objects.equals(additionalFields, that.additionalFields);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(clientId, displayName, description, enabled, appUrl, redirectUris, roles, additionalFields);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -1,297 +0,0 @@
|
||||
package org.keycloak.representations.admin.v2;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import org.keycloak.representations.admin.v2.validation.CreateClient;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||
import org.hibernate.validator.constraints.URL;
|
||||
|
||||
public class ClientRepresentation extends BaseRepresentation {
|
||||
|
||||
public static final String OIDC = "openid-connect";
|
||||
|
||||
@NotBlank(groups = CreateClient.class)
|
||||
@JsonPropertyDescription("ID uniquely identifying this client")
|
||||
private String clientId;
|
||||
|
||||
@JsonPropertyDescription("Human readable name of the client")
|
||||
private String displayName;
|
||||
|
||||
@JsonPropertyDescription("Human readable description of the client")
|
||||
private String description;
|
||||
|
||||
@JsonProperty(defaultValue = OIDC)
|
||||
@JsonPropertyDescription("The protocol used to communicate with the client")
|
||||
private String protocol;
|
||||
|
||||
@JsonPropertyDescription("Whether this client is enabled")
|
||||
private Boolean enabled;
|
||||
|
||||
@URL
|
||||
@JsonPropertyDescription("URL to the application's homepage that is represented by this client")
|
||||
private String appUrl;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@JsonPropertyDescription("URLs that the browser can redirect to after login")
|
||||
private Set<@NotBlank @URL(message = "Each redirect URL must be valid") String> appRedirectUrls = new LinkedHashSet<String>();
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@JsonPropertyDescription("Login flows that are enabled for this client")
|
||||
private Set<@NotBlank String> loginFlows = new LinkedHashSet<String>();
|
||||
|
||||
@Valid
|
||||
@JsonPropertyDescription("Authentication configuration for this client")
|
||||
private Auth auth;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@JsonPropertyDescription("Web origins that are allowed to make requests to this client")
|
||||
private Set<@NotBlank String> webOrigins = new LinkedHashSet<String>();
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@JsonPropertyDescription("Roles associated with this client")
|
||||
private Set<@NotBlank String> roles = new LinkedHashSet<String>();
|
||||
|
||||
@Valid
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@JsonPropertyDescription("Service account configuration for this client")
|
||||
private ServiceAccount serviceAccount;
|
||||
|
||||
public ClientRepresentation() {}
|
||||
|
||||
public ClientRepresentation(String clientId) {
|
||||
this.clientId = clientId;
|
||||
}
|
||||
|
||||
public String getClientId() {
|
||||
return clientId;
|
||||
}
|
||||
|
||||
public void setClientId(String clientId) {
|
||||
this.clientId = clientId;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getProtocol() {
|
||||
return protocol;
|
||||
}
|
||||
|
||||
public void setProtocol(String protocol) {
|
||||
this.protocol = protocol;
|
||||
}
|
||||
|
||||
public Boolean getEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getAppUrl() {
|
||||
return appUrl;
|
||||
}
|
||||
|
||||
public void setAppUrl(String appUrl) {
|
||||
this.appUrl = appUrl;
|
||||
}
|
||||
|
||||
public Set<String> getAppRedirectUrls() {
|
||||
return appRedirectUrls;
|
||||
}
|
||||
|
||||
public void setAppRedirectUrls(Set<String> appRedirectUrls) {
|
||||
this.appRedirectUrls = appRedirectUrls;
|
||||
}
|
||||
|
||||
public Set<String> getLoginFlows() {
|
||||
return loginFlows;
|
||||
}
|
||||
|
||||
public void setLoginFlows(Set<String> loginFlows) {
|
||||
this.loginFlows = loginFlows;
|
||||
}
|
||||
|
||||
public Auth getAuth() {
|
||||
return auth;
|
||||
}
|
||||
|
||||
public void setAuth(Auth auth) {
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
public Set<String> getWebOrigins() {
|
||||
return webOrigins;
|
||||
}
|
||||
|
||||
public void setWebOrigins(Set<String> webOrigins) {
|
||||
this.webOrigins = webOrigins;
|
||||
}
|
||||
|
||||
public Set<String> getRoles() {
|
||||
return roles;
|
||||
}
|
||||
|
||||
public void setRoles(Set<String> roles) {
|
||||
this.roles = roles;
|
||||
}
|
||||
|
||||
public ServiceAccount getServiceAccount() {
|
||||
return serviceAccount;
|
||||
}
|
||||
|
||||
public void setServiceAccount(ServiceAccount serviceAccount) {
|
||||
this.serviceAccount = serviceAccount;
|
||||
}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||
public static class Auth {
|
||||
|
||||
@JsonPropertyDescription("Whether authentication is enabled for this client")
|
||||
private Boolean enabled;
|
||||
|
||||
@JsonPropertyDescription("Which authentication method is used for this client")
|
||||
private String method;
|
||||
|
||||
@JsonPropertyDescription("Secret used to authenticate this client with Secret authentication")
|
||||
private String secret;
|
||||
|
||||
@JsonPropertyDescription("Public key used to authenticate this client with Signed JWT authentication")
|
||||
private String certificate;
|
||||
|
||||
public Boolean getEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getMethod() {
|
||||
return method;
|
||||
}
|
||||
|
||||
public void setMethod(String method) {
|
||||
this.method = method;
|
||||
}
|
||||
|
||||
public String getSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
public void setSecret(String secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
public String getCertificate() {
|
||||
return certificate;
|
||||
}
|
||||
|
||||
public void setCertificate(String certificate) {
|
||||
this.certificate = certificate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof Auth auth)) return false;
|
||||
return Objects.equals(enabled, auth.enabled)
|
||||
&& Objects.equals(method, auth.method)
|
||||
&& Objects.equals(secret, auth.secret)
|
||||
&& Objects.equals(certificate, auth.certificate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(enabled, method, secret, certificate);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||
public static class ServiceAccount {
|
||||
|
||||
@JsonPropertyDescription("Whether the service account is enabled")
|
||||
private Boolean enabled;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@JsonPropertyDescription("Roles assigned to the service account")
|
||||
private Set<String> roles = new LinkedHashSet<String>();
|
||||
|
||||
public Boolean getEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public Set<String> getRoles() {
|
||||
return roles;
|
||||
}
|
||||
|
||||
public void setRoles(Set<String> roles) {
|
||||
this.roles = roles;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof ServiceAccount that)) return false;
|
||||
return Objects.equals(enabled, that.enabled)
|
||||
&& Objects.equals(roles, that.roles);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(enabled, roles);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
ClientRepresentation that = (ClientRepresentation) o;
|
||||
return Objects.equals(clientId, that.clientId)
|
||||
&& Objects.equals(displayName, that.displayName)
|
||||
&& Objects.equals(description, that.description)
|
||||
&& Objects.equals(protocol, that.protocol)
|
||||
&& Objects.equals(enabled, that.enabled)
|
||||
&& Objects.equals(appUrl, that.appUrl)
|
||||
&& Objects.equals(appRedirectUrls, that.appRedirectUrls)
|
||||
&& Objects.equals(loginFlows, that.loginFlows)
|
||||
&& Objects.equals(auth, that.auth)
|
||||
&& Objects.equals(webOrigins, that.webOrigins)
|
||||
&& Objects.equals(roles, that.roles)
|
||||
&& Objects.equals(serviceAccount, that.serviceAccount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(clientId, displayName, description, protocol, enabled, appUrl, appRedirectUrls,
|
||||
loginFlows, auth, webOrigins, roles, serviceAccount
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,146 @@
|
||||
package org.keycloak.representations.admin.v2;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||
|
||||
@Schema
|
||||
public class OIDCClientRepresentation extends BaseClientRepresentation {
|
||||
public static final String PROTOCOL = "openid-connect";
|
||||
|
||||
public enum Flow {
|
||||
STANDARD,
|
||||
IMPLICIT,
|
||||
DIRECT_GRANT,
|
||||
SERVICE_ACCOUNT,
|
||||
TOKEN_EXCHANGE,
|
||||
DEVICE,
|
||||
CIBA
|
||||
}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@JsonPropertyDescription("Login flows that are enabled for this client")
|
||||
private Set<Flow> loginFlows = new LinkedHashSet<>();
|
||||
|
||||
@Valid
|
||||
@JsonPropertyDescription("Authentication configuration for this client")
|
||||
private Auth auth;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@JsonPropertyDescription("Web origins that are allowed to make requests to this client")
|
||||
private Set<@NotBlank String> webOrigins = new LinkedHashSet<>();
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
@JsonPropertyDescription("Roles assigned to the service account")
|
||||
private Set<@NotBlank String> serviceAccountRoles = new LinkedHashSet<>();
|
||||
|
||||
public OIDCClientRepresentation() {}
|
||||
|
||||
public OIDCClientRepresentation(String clientId) {
|
||||
this.clientId = clientId;
|
||||
}
|
||||
|
||||
public Set<Flow> getLoginFlows() {
|
||||
return loginFlows;
|
||||
}
|
||||
|
||||
public void setLoginFlows(Set<Flow> loginFlows) {
|
||||
this.loginFlows = loginFlows;
|
||||
}
|
||||
|
||||
public Auth getAuth() {
|
||||
return auth;
|
||||
}
|
||||
|
||||
public void setAuth(Auth auth) {
|
||||
this.auth = auth;
|
||||
}
|
||||
|
||||
public Set<String> getWebOrigins() {
|
||||
return webOrigins;
|
||||
}
|
||||
|
||||
public void setWebOrigins(Set<String> webOrigins) {
|
||||
this.webOrigins = webOrigins;
|
||||
}
|
||||
|
||||
public Set<String> getServiceAccountRoles() {
|
||||
return serviceAccountRoles;
|
||||
}
|
||||
|
||||
public void setServiceAccountRoles(Set<String> serviceAccountRoles) {
|
||||
this.serviceAccountRoles = serviceAccountRoles;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProtocol() {
|
||||
return PROTOCOL;
|
||||
}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||
public static class Auth {
|
||||
|
||||
@JsonPropertyDescription("Which authentication method is used for this client")
|
||||
private String method;
|
||||
|
||||
@JsonPropertyDescription("Secret used to authenticate this client with Secret authentication")
|
||||
private String secret;
|
||||
|
||||
@JsonPropertyDescription("Public key used to authenticate this client with Signed JWT authentication")
|
||||
private String certificate;
|
||||
|
||||
public String getMethod() {
|
||||
return method;
|
||||
}
|
||||
|
||||
public void setMethod(String method) {
|
||||
this.method = method;
|
||||
}
|
||||
|
||||
public String getSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
public void setSecret(String secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
public String getCertificate() {
|
||||
return certificate;
|
||||
}
|
||||
|
||||
public void setCertificate(String certificate) {
|
||||
this.certificate = certificate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof Auth auth)) return false;
|
||||
return Objects.equals(method, auth.method) && Objects.equals(secret, auth.secret) && Objects.equals(certificate, auth.certificate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(method, secret, certificate);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof OIDCClientRepresentation that)) return false;
|
||||
if (!super.equals(o)) return false;
|
||||
return Objects.equals(loginFlows, that.loginFlows) && Objects.equals(auth, that.auth) && Objects.equals(webOrigins, that.webOrigins) && Objects.equals(serviceAccountRoles, that.serviceAccountRoles);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(super.hashCode(), loginFlows, auth, webOrigins, serviceAccountRoles);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package org.keycloak.representations.admin.v2;
|
||||
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* @author Vaclav Muzikar <vmuzikar@redhat.com>
|
||||
*/
|
||||
@Schema
|
||||
public class SAMLClientRepresentation extends BaseClientRepresentation {
|
||||
public static final String PROTOCOL = "saml";
|
||||
|
||||
@Override
|
||||
public String getProtocol() {
|
||||
return PROTOCOL;
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@ import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
|
||||
import org.keycloak.services.Service;
|
||||
import org.keycloak.services.ServiceException;
|
||||
|
||||
@ -25,14 +25,14 @@ public interface ClientService extends Service {
|
||||
// NOTE: this is not always the most desirable way to do pagination
|
||||
}
|
||||
|
||||
record CreateOrUpdateResult(ClientRepresentation representation, boolean created) {}
|
||||
record CreateOrUpdateResult(BaseClientRepresentation representation, boolean created) {}
|
||||
|
||||
Optional<ClientRepresentation> getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions);
|
||||
Optional<BaseClientRepresentation> getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions);
|
||||
|
||||
Stream<ClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions, ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions);
|
||||
Stream<BaseClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions, ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions);
|
||||
|
||||
Stream<ClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions);
|
||||
Stream<BaseClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions);
|
||||
|
||||
CreateOrUpdateResult createOrUpdate(RealmModel realm, ClientRepresentation client, boolean allowUpdate) throws ServiceException;
|
||||
CreateOrUpdateResult createOrUpdate(RealmModel realm, BaseClientRepresentation client, boolean allowUpdate) throws ServiceException;
|
||||
|
||||
}
|
||||
|
||||
@ -15,9 +15,9 @@ import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.RoleModel;
|
||||
import org.keycloak.models.mapper.ClientModelMapper;
|
||||
import org.keycloak.models.mapper.MapStructModelMapper;
|
||||
import org.keycloak.models.utils.ModelToRepresentation;
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
|
||||
import org.keycloak.representations.admin.v2.OIDCClientRepresentation;
|
||||
import org.keycloak.representations.admin.v2.validation.CreateClientDefault;
|
||||
import org.keycloak.representations.idm.RoleRepresentation;
|
||||
import org.keycloak.services.ServiceException;
|
||||
@ -30,7 +30,6 @@ import org.keycloak.validation.jakarta.JakartaValidatorProvider;
|
||||
// TODO
|
||||
public class DefaultClientService implements ClientService {
|
||||
private final KeycloakSession session;
|
||||
private final ClientModelMapper mapper;
|
||||
private final JakartaValidatorProvider validator;
|
||||
private final RealmAdminResource realmAdminResource;
|
||||
private final ClientsResource clientsResource;
|
||||
@ -42,7 +41,6 @@ public class DefaultClientService implements ClientService {
|
||||
this.clientResource = clientResource;
|
||||
|
||||
this.clientsResource = realmAdminResource.getClients();
|
||||
this.mapper = new MapStructModelMapper().clients();
|
||||
this.validator = new HibernateValidatorProvider();
|
||||
}
|
||||
|
||||
@ -51,48 +49,58 @@ public class DefaultClientService implements ClientService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ClientRepresentation> getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions) {
|
||||
public Optional<BaseClientRepresentation> getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions) {
|
||||
// TODO: is the access map on the representation needed
|
||||
return Optional.ofNullable(clientResource).map(ClientResource::viewClientModel).map(model -> mapper.fromModel(session, model));
|
||||
return Optional.ofNullable(clientResource).map(ClientResource::viewClientModel)
|
||||
.map(model -> session.getProvider(ClientModelMapper.class, model.getProtocol()).fromModel(model));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions,
|
||||
public Stream<BaseClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions,
|
||||
ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions) {
|
||||
// TODO: is the access map on the representation needed
|
||||
return clientsResource.getClientModels(null, true, false, null, null, null).map(model -> mapper.fromModel(session, model));
|
||||
return clientsResource.getClientModels(null, true, false, null, null, null)
|
||||
.map(model -> session.getProvider(ClientModelMapper.class, model.getProtocol()).fromModel(model));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CreateOrUpdateResult createOrUpdate(RealmModel realm, ClientRepresentation client, boolean allowUpdate) throws ServiceException {
|
||||
public CreateOrUpdateResult createOrUpdate(RealmModel realm, BaseClientRepresentation client, boolean allowUpdate) throws ServiceException {
|
||||
boolean created = false;
|
||||
ClientModel model;
|
||||
ClientModelMapper mapper = session.getProvider(ClientModelMapper.class, client.getProtocol());
|
||||
|
||||
if (mapper == null) {
|
||||
throw new ServiceException("Mapper not found, unsupported client protocol: " + client.getProtocol(), Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (clientResource != null) {
|
||||
if (!allowUpdate) {
|
||||
throw new ServiceException("Client already exists", Response.Status.CONFLICT);
|
||||
}
|
||||
model = mapper.toModel(session, realm, clientResource.viewClientModel(), client);
|
||||
model = mapper.toModel(client, clientResource.viewClientModel());
|
||||
var rep = ModelToRepresentation.toRepresentation(model, session);
|
||||
clientResource.update(rep);
|
||||
} else {
|
||||
created = true;
|
||||
validator.validate(client, CreateClientDefault.class); // TODO improve it to avoid second validation when we know it is create and not update
|
||||
|
||||
model = mapper.toModel(session, realm, client);
|
||||
model = mapper.toModel(client);
|
||||
var rep = ModelToRepresentation.toRepresentation(model, session);
|
||||
model = clientsResource.createClientModel(rep);
|
||||
clientResource = clientsResource.getClient(model.getId());
|
||||
}
|
||||
|
||||
handleRoles(client.getRoles());
|
||||
handleServiceAccount(model, client.getServiceAccount());
|
||||
var updated = mapper.fromModel(session, model);
|
||||
if (client instanceof OIDCClientRepresentation oidcClient) {
|
||||
handleServiceAccount(model, oidcClient);
|
||||
}
|
||||
var updated = mapper.fromModel(model);
|
||||
|
||||
return new CreateOrUpdateResult(updated, created);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions) {
|
||||
public Stream<BaseClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions) {
|
||||
// TODO Auto-generated method stub
|
||||
return null;
|
||||
}
|
||||
@ -128,56 +136,58 @@ public class DefaultClientService implements ClientService {
|
||||
* <p>
|
||||
* Reuses API v1 logic
|
||||
*/
|
||||
protected void handleServiceAccount(ClientModel model, ClientRepresentation.ServiceAccount serviceAccount) {
|
||||
if (serviceAccount != null && serviceAccount.getEnabled() != null) {
|
||||
ClientResource.updateClientServiceAccount(session, model, serviceAccount.getEnabled());
|
||||
protected void handleServiceAccount(ClientModel model, OIDCClientRepresentation rep) {
|
||||
boolean serviceAccountEnabled = rep.getLoginFlows().contains(OIDCClientRepresentation.Flow.SERVICE_ACCOUNT);
|
||||
|
||||
if (serviceAccount.getEnabled()) {
|
||||
var clientRoleResource = clientResource.getRoleContainerResource();
|
||||
var realmRoleResource = realmAdminResource.getRoleContainerResource();
|
||||
ClientResource.updateClientServiceAccount(session, model, serviceAccountEnabled);
|
||||
|
||||
var serviceAccountUser = session.users().getServiceAccount(model);
|
||||
var serviceAccountRoleResource = realmAdminResource.users().user(clientResource.getServiceAccountUser().getId()).getRoleMappings();
|
||||
if (!serviceAccountEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> desiredRoleNames = Optional.ofNullable(serviceAccount.getRoles()).orElse(Collections.emptySet());
|
||||
Set<RoleModel> currentRoles = serviceAccountUser.getRoleMappingsStream().collect(Collectors.toSet());
|
||||
Set<String> currentRoleNames = currentRoles.stream().map(RoleModel::getName).collect(Collectors.toSet());
|
||||
var clientRoleResource = clientResource.getRoleContainerResource();
|
||||
var realmRoleResource = realmAdminResource.getRoleContainerResource();
|
||||
|
||||
// Get missing roles (in desired but not in current)
|
||||
List<RoleRepresentation> missingRoles = desiredRoleNames.stream()
|
||||
.filter(roleName -> !currentRoleNames.contains(roleName))
|
||||
.map(roleName -> {
|
||||
try {
|
||||
return clientRoleResource.getRole(roleName); // client role
|
||||
} catch (NotFoundException e) {
|
||||
try {
|
||||
return realmRoleResource.getRole(roleName); // realm role
|
||||
} catch (NotFoundException e2) {
|
||||
throw new ServiceException("Cannot assign role to the service account (field 'serviceAccount.roles') as it does not exist", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
})
|
||||
.toList();
|
||||
var serviceAccountUser = session.users().getServiceAccount(model);
|
||||
var serviceAccountRoleResource = realmAdminResource.users().user(clientResource.getServiceAccountUser().getId()).getRoleMappings();
|
||||
|
||||
// Add missing roles (in desired but not in current)
|
||||
if (!missingRoles.isEmpty()) {
|
||||
serviceAccountRoleResource.addRealmRoleMappings(missingRoles);
|
||||
}
|
||||
Set<String> desiredRoleNames = Optional.ofNullable(rep.getServiceAccountRoles()).orElse(Collections.emptySet());
|
||||
Set<RoleModel> currentRoles = serviceAccountUser.getRoleMappingsStream().collect(Collectors.toSet());
|
||||
Set<String> currentRoleNames = currentRoles.stream().map(RoleModel::getName).collect(Collectors.toSet());
|
||||
|
||||
// Get extra roles (in current but not in desired)
|
||||
List<RoleRepresentation> extraRoles = currentRoles.stream()
|
||||
.filter(role -> !desiredRoleNames.contains(role.getName()))
|
||||
.map(ModelToRepresentation::toRepresentation)
|
||||
.toList();
|
||||
|
||||
// Remove extra roles (in current but not in desired)
|
||||
if (!extraRoles.isEmpty()) {
|
||||
// Get missing roles (in desired but not in current)
|
||||
List<RoleRepresentation> missingRoles = desiredRoleNames.stream()
|
||||
.filter(roleName -> !currentRoleNames.contains(roleName))
|
||||
.map(roleName -> {
|
||||
try {
|
||||
serviceAccountRoleResource.deleteRealmRoleMappings(extraRoles);
|
||||
return clientRoleResource.getRole(roleName); // client role
|
||||
} catch (NotFoundException e) {
|
||||
throw new ServiceException("Cannot unassign role from the service account (field 'serviceAccount.roles') as it does not exist", Response.Status.BAD_REQUEST);
|
||||
try {
|
||||
return realmRoleResource.getRole(roleName); // realm role
|
||||
} catch (NotFoundException e2) {
|
||||
throw new ServiceException("Cannot assign role to the service account (field 'serviceAccount.roles') as it does not exist", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.toList();
|
||||
|
||||
// Add missing roles (in desired but not in current)
|
||||
if (!missingRoles.isEmpty()) {
|
||||
serviceAccountRoleResource.addRealmRoleMappings(missingRoles);
|
||||
}
|
||||
|
||||
// Get extra roles (in current but not in desired)
|
||||
List<RoleRepresentation> extraRoles = currentRoles.stream()
|
||||
.filter(role -> !desiredRoleNames.contains(role.getName()))
|
||||
.map(ModelToRepresentation::toRepresentation)
|
||||
.toList();
|
||||
|
||||
// Remove extra roles (in current but not in desired)
|
||||
if (!extraRoles.isEmpty()) {
|
||||
try {
|
||||
serviceAccountRoleResource.deleteRealmRoleMappings(extraRoles);
|
||||
} catch (NotFoundException e) {
|
||||
throw new ServiceException("Cannot unassign role from the service account (field 'serviceAccount.roles') as it does not exist", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
org.keycloak.models.mapper.OIDCClientModelMapperFactory
|
||||
@ -0,0 +1 @@
|
||||
org.keycloak.models.mapper.ClientModelMapperSpi
|
||||
@ -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)
|
||||
|
||||
@ -12,7 +12,7 @@ import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
|
||||
import org.keycloak.services.resources.KeycloakOpenAPI;
|
||||
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
@ -27,16 +27,16 @@ public interface ClientsApi {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Get all clients", description = "Returns a list of all clients in the realm")
|
||||
Stream<ClientRepresentation> getClients();
|
||||
Stream<BaseClientRepresentation> getClients();
|
||||
|
||||
/**
|
||||
* @return {@link ClientRepresentation} of created client
|
||||
* @return {@link BaseClientRepresentation} of created client
|
||||
*/
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Create a new client", description = "Creates a new client in the realm")
|
||||
Response createClient(@Valid ClientRepresentation client);
|
||||
Response createClient(@Valid BaseClientRepresentation client);
|
||||
|
||||
@Path("{id}")
|
||||
ClientApi client(@PathParam("id") String id);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import jakarta.ws.rs.core.Response;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
|
||||
import org.keycloak.representations.admin.v2.validation.CreateClientDefault;
|
||||
import org.keycloak.services.ServiceException;
|
||||
import org.keycloak.services.client.ClientService;
|
||||
@ -40,12 +40,12 @@ public class DefaultClientsApi implements ClientsApi {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientRepresentation> getClients() {
|
||||
public Stream<BaseClientRepresentation> getClients() {
|
||||
return clientService.getClients(realm, null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response createClient(@Valid ClientRepresentation client) {
|
||||
public Response createClient(@Valid BaseClientRepresentation client) {
|
||||
try {
|
||||
DefaultClientApi.validateUnknownFields(client);
|
||||
validator.validate(client, CreateClientDefault.class);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user