mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 15:02:05 -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 -->
|
<!-- Used to test SAML Galleon feature-pack layers discovery -->
|
||||||
<version.org.wildfly.glow>1.0.0.Alpha8</version.org.wildfly.glow>
|
<version.org.wildfly.glow>1.0.0.Alpha8</version.org.wildfly.glow>
|
||||||
|
|
||||||
<org.mapstruct.version>1.6.3</org.mapstruct.version>
|
|
||||||
|
|
||||||
<!-- Galleon -->
|
<!-- Galleon -->
|
||||||
<galleon.fork.embedded>true</galleon.fork.embedded>
|
<galleon.fork.embedded>true</galleon.fork.embedded>
|
||||||
<galleon.log.time>true</galleon.log.time>
|
<galleon.log.time>true</galleon.log.time>
|
||||||
@ -399,11 +397,6 @@
|
|||||||
<artifactId>xsom</artifactId>
|
<artifactId>xsom</artifactId>
|
||||||
<version>${org.glassfish.jaxb.xsom.version}</version>
|
<version>${org.glassfish.jaxb.xsom.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>org.mapstruct</groupId>
|
|
||||||
<artifactId>mapstruct</artifactId>
|
|
||||||
<version>${org.mapstruct.version}</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.bouncycastle</groupId>
|
<groupId>org.bouncycastle</groupId>
|
||||||
|
|||||||
@ -139,12 +139,6 @@
|
|||||||
<artifactId>rdf-urdna</artifactId>
|
<artifactId>rdf-urdna</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.mapstruct</groupId>
|
|
||||||
<artifactId>mapstruct</artifactId>
|
|
||||||
<version>${org.mapstruct.version}</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Hibernate validator -->
|
<!-- Hibernate validator -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
|
|||||||
@ -1,99 +1,246 @@
|
|||||||
package org.keycloak.quarkus.runtime.oas;
|
package org.keycloak.quarkus.runtime.oas;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||||
import io.quarkus.smallrye.openapi.OpenApiFilter;
|
import io.quarkus.smallrye.openapi.OpenApiFilter;
|
||||||
import org.eclipse.microprofile.openapi.OASFactory;
|
import org.eclipse.microprofile.openapi.OASFactory;
|
||||||
import org.eclipse.microprofile.openapi.OASFilter;
|
import org.eclipse.microprofile.openapi.OASFilter;
|
||||||
import org.eclipse.microprofile.openapi.models.OpenAPI;
|
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.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)
|
@OpenApiFilter(OpenApiFilter.RunStage.BUILD)
|
||||||
public class OASModelFilter implements OASFilter {
|
public class OASModelFilter implements OASFilter {
|
||||||
|
|
||||||
@Override
|
private final IndexView index;
|
||||||
public void filterOpenAPI(OpenAPI openAPI) {
|
private final Logger log = Logger.getLogger(OASModelFilter.class);
|
||||||
// Filter Paths that have the '/admin/api/v2' prefix
|
private final Map<String, ClassInfo> simpleNameToClassInfoMap = new HashMap<>();
|
||||||
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())
|
|
||||||
));
|
|
||||||
|
|
||||||
// Replace ALL Paths with filtered Paths
|
public static final String REF_PREFIX = "#/components/schemas/";
|
||||||
var paths = OASFactory.createPaths();
|
|
||||||
newPaths.forEach(paths::addPathItem);
|
|
||||||
openAPI.setPaths(paths);
|
|
||||||
|
|
||||||
// Compute tags that are actually used by remaining operations
|
public OASModelFilter(IndexView indexView) {
|
||||||
Set<String> usedTags = newPaths.values().stream()
|
this.index = indexView;
|
||||||
.flatMap(pi -> operationsOf(pi).stream())
|
log.debug("Index size: " + indexView.getKnownClasses().size());
|
||||||
.flatMap(op -> Optional.ofNullable(op.getTags()).orElseGet(List::of).stream())
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
|
|
||||||
// Drop top-level tags not used anywhere
|
indexView.getKnownClasses().forEach(classInfo -> {
|
||||||
if (openAPI.getTags() != null) {
|
simpleNameToClassInfoMap.put(classInfo.simpleName(), classInfo);
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sortedPathItem.setSummary(pathItem.getSummary());
|
@Override
|
||||||
sortedPathItem.setDescription(pathItem.getDescription());
|
public void filterOpenAPI(OpenAPI openAPI) {
|
||||||
sortedPathItem.setServers(pathItem.getServers());
|
// Sort Paths
|
||||||
sortedPathItem.setParameters(pathItem.getParameters());
|
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) {
|
Map<String, Set<Schema>> discriminatorPropertiesToBeAdded = new HashMap<>();
|
||||||
List<Operation> ops = new ArrayList<>(8);
|
|
||||||
if (pi.getGET() != null) ops.add(pi.getGET());
|
// Reflect Jackson annotations in OpenAPI spec
|
||||||
if (pi.getPOST() != null) ops.add(pi.getPOST());
|
// Follows https://swagger.io/docs/specification/v3_0/data-models/inheritance-and-polymorphism/
|
||||||
if (pi.getPUT() != null) ops.add(pi.getPUT());
|
openAPI.getPaths().getPathItems().values().stream()
|
||||||
if (pi.getPATCH() != null) ops.add(pi.getPATCH());
|
.flatMap(p -> p.getOperations().values().stream())
|
||||||
if (pi.getDELETE() != null) ops.add(pi.getDELETE());
|
.forEach(operation -> {
|
||||||
if (pi.getHEAD() != null) ops.add(pi.getHEAD());
|
// This is not nice but so is the model structure...
|
||||||
if (pi.getOPTIONS() != null) ops.add(pi.getOPTIONS());
|
|
||||||
if (pi.getTRACE() != null) ops.add(pi.getTRACE());
|
// Request body
|
||||||
return ops;
|
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.path=${quarkus.smallrye-openapi.path}/ui
|
||||||
quarkus.swagger-ui.always-include=true
|
quarkus.swagger-ui.always-include=true
|
||||||
quarkus.swagger-ui.filter=true
|
quarkus.swagger-ui.filter=true
|
||||||
mp.openapi.filter=org.keycloak.quarkus.runtime.oas.OASModelFilter
|
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.remove-unused-schemas.enable=true
|
mp.openapi.extensions.smallrye.auto-inheritance=PARENT_ONLY
|
||||||
|
|
||||||
# Disable Error messages from smallrye.openapi
|
# Disable Error messages from smallrye.openapi
|
||||||
# related issue: https://github.com/keycloak/keycloak/issues/41871
|
# 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 org.keycloak.it.utils.KeycloakDistribution;
|
||||||
|
|
||||||
import io.quarkus.test.junit.main.Launch;
|
import io.quarkus.test.junit.main.Launch;
|
||||||
|
import io.restassured.response.ValidatableResponse;
|
||||||
import org.junit.jupiter.api.Tag;
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
import static io.restassured.RestAssured.when;
|
import static io.restassured.RestAssured.when;
|
||||||
|
import static org.hamcrest.Matchers.equalTo;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
@DistributionTest(keepAlive = true, requestPort = 9000, containerExposedPorts = {8080, 9000})
|
@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 = 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");
|
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>
|
<groupId>jakarta.ws.rs</groupId>
|
||||||
<artifactId>jakarta.ws.rs-api</artifactId>
|
<artifactId>jakarta.ws.rs-api</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>org.mapstruct</groupId>
|
|
||||||
<artifactId>mapstruct</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>jakarta.enterprise</groupId>
|
<groupId>jakarta.enterprise</groupId>
|
||||||
<artifactId>jakarta.enterprise.cdi-api</artifactId>
|
<artifactId>jakarta.enterprise.cdi-api</artifactId>
|
||||||
@ -64,13 +60,6 @@
|
|||||||
<compilerArgument>
|
<compilerArgument>
|
||||||
-AgeneratedTranslationFilesPath=${project.build.directory}/generated-translation-files
|
-AgeneratedTranslationFilesPath=${project.build.directory}/generated-translation-files
|
||||||
</compilerArgument>
|
</compilerArgument>
|
||||||
<annotationProcessorPaths>
|
|
||||||
<path>
|
|
||||||
<groupId>org.mapstruct</groupId>
|
|
||||||
<artifactId>mapstruct-processor</artifactId>
|
|
||||||
<version>${org.mapstruct.version}</version>
|
|
||||||
</path>
|
|
||||||
</annotationProcessorPaths>
|
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</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;
|
package org.keycloak.models.mapper;
|
||||||
|
|
||||||
import org.keycloak.models.ClientModel;
|
import org.keycloak.models.ClientModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.provider.Provider;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.representations.admin.v2.BaseClientRepresentation;
|
||||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
|
||||||
import org.keycloak.services.ServiceException;
|
|
||||||
|
|
||||||
public interface ClientModelMapper {
|
/**
|
||||||
|
* @author Vaclav Muzikar <vmuzikar@redhat.com>
|
||||||
ClientRepresentation fromModel(KeycloakSession session, ClientModel model);
|
*/
|
||||||
|
public interface ClientModelMapper extends Provider, RepModelMapper<BaseClientRepresentation, ClientModel> {
|
||||||
ClientModel toModel(KeycloakSession session, RealmModel realm, ClientModel existingModel, ClientRepresentation rep) throws ServiceException;
|
|
||||||
|
|
||||||
ClientModel toModel(KeycloakSession session, RealmModel realm, ClientRepresentation rep) throws ServiceException;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.JsonAnySetter;
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||||
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||||
|
@Schema
|
||||||
public class BaseRepresentation {
|
public class BaseRepresentation {
|
||||||
|
|
||||||
@JsonIgnore
|
@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 java.util.stream.Stream;
|
||||||
|
|
||||||
import org.keycloak.models.RealmModel;
|
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.Service;
|
||||||
import org.keycloak.services.ServiceException;
|
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
|
// 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.RealmModel;
|
||||||
import org.keycloak.models.RoleModel;
|
import org.keycloak.models.RoleModel;
|
||||||
import org.keycloak.models.mapper.ClientModelMapper;
|
import org.keycloak.models.mapper.ClientModelMapper;
|
||||||
import org.keycloak.models.mapper.MapStructModelMapper;
|
|
||||||
import org.keycloak.models.utils.ModelToRepresentation;
|
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.admin.v2.validation.CreateClientDefault;
|
||||||
import org.keycloak.representations.idm.RoleRepresentation;
|
import org.keycloak.representations.idm.RoleRepresentation;
|
||||||
import org.keycloak.services.ServiceException;
|
import org.keycloak.services.ServiceException;
|
||||||
@ -30,7 +30,6 @@ import org.keycloak.validation.jakarta.JakartaValidatorProvider;
|
|||||||
// TODO
|
// TODO
|
||||||
public class DefaultClientService implements ClientService {
|
public class DefaultClientService implements ClientService {
|
||||||
private final KeycloakSession session;
|
private final KeycloakSession session;
|
||||||
private final ClientModelMapper mapper;
|
|
||||||
private final JakartaValidatorProvider validator;
|
private final JakartaValidatorProvider validator;
|
||||||
private final RealmAdminResource realmAdminResource;
|
private final RealmAdminResource realmAdminResource;
|
||||||
private final ClientsResource clientsResource;
|
private final ClientsResource clientsResource;
|
||||||
@ -42,7 +41,6 @@ public class DefaultClientService implements ClientService {
|
|||||||
this.clientResource = clientResource;
|
this.clientResource = clientResource;
|
||||||
|
|
||||||
this.clientsResource = realmAdminResource.getClients();
|
this.clientsResource = realmAdminResource.getClients();
|
||||||
this.mapper = new MapStructModelMapper().clients();
|
|
||||||
this.validator = new HibernateValidatorProvider();
|
this.validator = new HibernateValidatorProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,48 +49,58 @@ public class DefaultClientService implements ClientService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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
|
// 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
|
@Override
|
||||||
public Stream<ClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions,
|
public Stream<BaseClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions,
|
||||||
ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions) {
|
ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions) {
|
||||||
// TODO: is the access map on the representation needed
|
// 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
|
@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;
|
boolean created = false;
|
||||||
ClientModel model;
|
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 (clientResource != null) {
|
||||||
if (!allowUpdate) {
|
if (!allowUpdate) {
|
||||||
throw new ServiceException("Client already exists", Response.Status.CONFLICT);
|
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);
|
var rep = ModelToRepresentation.toRepresentation(model, session);
|
||||||
clientResource.update(rep);
|
clientResource.update(rep);
|
||||||
} else {
|
} else {
|
||||||
created = true;
|
created = true;
|
||||||
validator.validate(client, CreateClientDefault.class); // TODO improve it to avoid second validation when we know it is create and not update
|
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);
|
var rep = ModelToRepresentation.toRepresentation(model, session);
|
||||||
model = clientsResource.createClientModel(rep);
|
model = clientsResource.createClientModel(rep);
|
||||||
clientResource = clientsResource.getClient(model.getId());
|
clientResource = clientsResource.getClient(model.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRoles(client.getRoles());
|
handleRoles(client.getRoles());
|
||||||
handleServiceAccount(model, client.getServiceAccount());
|
if (client instanceof OIDCClientRepresentation oidcClient) {
|
||||||
var updated = mapper.fromModel(session, model);
|
handleServiceAccount(model, oidcClient);
|
||||||
|
}
|
||||||
|
var updated = mapper.fromModel(model);
|
||||||
|
|
||||||
return new CreateOrUpdateResult(updated, created);
|
return new CreateOrUpdateResult(updated, created);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Stream<ClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions) {
|
public Stream<BaseClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions) {
|
||||||
// TODO Auto-generated method stub
|
// TODO Auto-generated method stub
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -128,56 +136,58 @@ public class DefaultClientService implements ClientService {
|
|||||||
* <p>
|
* <p>
|
||||||
* Reuses API v1 logic
|
* Reuses API v1 logic
|
||||||
*/
|
*/
|
||||||
protected void handleServiceAccount(ClientModel model, ClientRepresentation.ServiceAccount serviceAccount) {
|
protected void handleServiceAccount(ClientModel model, OIDCClientRepresentation rep) {
|
||||||
if (serviceAccount != null && serviceAccount.getEnabled() != null) {
|
boolean serviceAccountEnabled = rep.getLoginFlows().contains(OIDCClientRepresentation.Flow.SERVICE_ACCOUNT);
|
||||||
ClientResource.updateClientServiceAccount(session, model, serviceAccount.getEnabled());
|
|
||||||
|
|
||||||
if (serviceAccount.getEnabled()) {
|
ClientResource.updateClientServiceAccount(session, model, serviceAccountEnabled);
|
||||||
var clientRoleResource = clientResource.getRoleContainerResource();
|
|
||||||
var realmRoleResource = realmAdminResource.getRoleContainerResource();
|
|
||||||
|
|
||||||
var serviceAccountUser = session.users().getServiceAccount(model);
|
if (!serviceAccountEnabled) {
|
||||||
var serviceAccountRoleResource = realmAdminResource.users().user(clientResource.getServiceAccountUser().getId()).getRoleMappings();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Set<String> desiredRoleNames = Optional.ofNullable(serviceAccount.getRoles()).orElse(Collections.emptySet());
|
var clientRoleResource = clientResource.getRoleContainerResource();
|
||||||
Set<RoleModel> currentRoles = serviceAccountUser.getRoleMappingsStream().collect(Collectors.toSet());
|
var realmRoleResource = realmAdminResource.getRoleContainerResource();
|
||||||
Set<String> currentRoleNames = currentRoles.stream().map(RoleModel::getName).collect(Collectors.toSet());
|
|
||||||
|
|
||||||
// Get missing roles (in desired but not in current)
|
var serviceAccountUser = session.users().getServiceAccount(model);
|
||||||
List<RoleRepresentation> missingRoles = desiredRoleNames.stream()
|
var serviceAccountRoleResource = realmAdminResource.users().user(clientResource.getServiceAccountUser().getId()).getRoleMappings();
|
||||||
.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();
|
|
||||||
|
|
||||||
// Add missing roles (in desired but not in current)
|
Set<String> desiredRoleNames = Optional.ofNullable(rep.getServiceAccountRoles()).orElse(Collections.emptySet());
|
||||||
if (!missingRoles.isEmpty()) {
|
Set<RoleModel> currentRoles = serviceAccountUser.getRoleMappingsStream().collect(Collectors.toSet());
|
||||||
serviceAccountRoleResource.addRealmRoleMappings(missingRoles);
|
Set<String> currentRoleNames = currentRoles.stream().map(RoleModel::getName).collect(Collectors.toSet());
|
||||||
}
|
|
||||||
|
|
||||||
// Get extra roles (in current but not in desired)
|
// Get missing roles (in desired but not in current)
|
||||||
List<RoleRepresentation> extraRoles = currentRoles.stream()
|
List<RoleRepresentation> missingRoles = desiredRoleNames.stream()
|
||||||
.filter(role -> !desiredRoleNames.contains(role.getName()))
|
.filter(roleName -> !currentRoleNames.contains(roleName))
|
||||||
.map(ModelToRepresentation::toRepresentation)
|
.map(roleName -> {
|
||||||
.toList();
|
|
||||||
|
|
||||||
// Remove extra roles (in current but not in desired)
|
|
||||||
if (!extraRoles.isEmpty()) {
|
|
||||||
try {
|
try {
|
||||||
serviceAccountRoleResource.deleteRealmRoleMappings(extraRoles);
|
return clientRoleResource.getRole(roleName); // client role
|
||||||
} catch (NotFoundException e) {
|
} 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.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
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;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
|
||||||
@ -21,20 +21,20 @@ public interface ClientApi {
|
|||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
ClientRepresentation getClient();
|
BaseClientRepresentation getClient();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return {@link ClientRepresentation} of created/updated client
|
* @return {@link BaseClientRepresentation} of created/updated client
|
||||||
*/
|
*/
|
||||||
@PUT
|
@PUT
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
Response createOrUpdateClient(@Valid ClientRepresentation client);
|
Response createOrUpdateClient(@Valid BaseClientRepresentation client);
|
||||||
|
|
||||||
@PATCH
|
@PATCH
|
||||||
@Consumes(CONTENT_TYPE_MERGE_PATCH)
|
@Consumes(CONTENT_TYPE_MERGE_PATCH)
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
ClientRepresentation patchClient(JsonNode patch);
|
BaseClientRepresentation patchClient(JsonNode patch);
|
||||||
|
|
||||||
@DELETE
|
@DELETE
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import jakarta.ws.rs.Produces;
|
|||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
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.keycloak.services.resources.KeycloakOpenAPI;
|
||||||
|
|
||||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||||
@ -27,16 +27,16 @@ public interface ClientsApi {
|
|||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
@Operation(summary = "Get all clients", description = "Returns a list of all clients in the realm")
|
@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
|
@POST
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Operation(summary = "Create a new client", description = "Creates a new client in the realm")
|
@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}")
|
@Path("{id}")
|
||||||
ClientApi client(@PathParam("id") String 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.ClientModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.RealmModel;
|
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.ErrorResponse;
|
||||||
import org.keycloak.services.ServiceException;
|
import org.keycloak.services.ServiceException;
|
||||||
import org.keycloak.services.client.ClientService;
|
import org.keycloak.services.client.ClientService;
|
||||||
@ -52,13 +52,13 @@ public class DefaultClientApi implements ClientApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ClientRepresentation getClient() {
|
public BaseClientRepresentation getClient() {
|
||||||
return clientService.getClient(realm, client.getClientId(), null)
|
return clientService.getClient(realm, client.getClientId(), null)
|
||||||
.orElseThrow(() -> new NotFoundException("Cannot find the specified client"));
|
.orElseThrow(() -> new NotFoundException("Cannot find the specified client"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Response createOrUpdateClient(ClientRepresentation client) {
|
public Response createOrUpdateClient(BaseClientRepresentation client) {
|
||||||
try {
|
try {
|
||||||
if (!Objects.equals(clientId, client.getClientId())) {
|
if (!Objects.equals(clientId, client.getClientId())) {
|
||||||
throw new WebApplicationException("cliendId in payload does not match the clientId in the path", Response.Status.BAD_REQUEST);
|
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
|
@Override
|
||||||
public ClientRepresentation patchClient(JsonNode patch) {
|
public BaseClientRepresentation patchClient(JsonNode patch) {
|
||||||
ClientRepresentation client = getClient();
|
BaseClientRepresentation client = getClient();
|
||||||
try {
|
try {
|
||||||
String contentType = session.getContext().getHttpRequest().getHttpHeaders().getHeaderString(HttpHeaders.CONTENT_TYPE);
|
String contentType = session.getContext().getHttpRequest().getHttpHeaders().getHeaderString(HttpHeaders.CONTENT_TYPE);
|
||||||
MediaType mediaType = contentType == null ? null : MediaType.valueOf(contentType);
|
MediaType mediaType = contentType == null ? null : MediaType.valueOf(contentType);
|
||||||
@ -83,7 +83,7 @@ public class DefaultClientApi implements ClientApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final ObjectReader objectReader = objectMapper.readerForUpdating(client);
|
final ObjectReader objectReader = objectMapper.readerForUpdating(client);
|
||||||
ClientRepresentation updated = objectReader.readValue(patch);
|
BaseClientRepresentation updated = objectReader.readValue(patch);
|
||||||
|
|
||||||
validateUnknownFields(updated);
|
validateUnknownFields(updated);
|
||||||
return clientService.createOrUpdate(realm, updated, true).representation();
|
return clientService.createOrUpdate(realm, updated, true).representation();
|
||||||
@ -104,8 +104,8 @@ public class DefaultClientApi implements ClientApi {
|
|||||||
clientResource.deleteClient();
|
clientResource.deleteClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void validateUnknownFields(ClientRepresentation rep) {
|
static void validateUnknownFields(BaseClientRepresentation rep) {
|
||||||
if (!rep.getAdditionalFields().isEmpty()) {
|
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);
|
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.KeycloakSession;
|
||||||
import org.keycloak.models.RealmModel;
|
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.representations.admin.v2.validation.CreateClientDefault;
|
||||||
import org.keycloak.services.ServiceException;
|
import org.keycloak.services.ServiceException;
|
||||||
import org.keycloak.services.client.ClientService;
|
import org.keycloak.services.client.ClientService;
|
||||||
@ -40,12 +40,12 @@ public class DefaultClientsApi implements ClientsApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Stream<ClientRepresentation> getClients() {
|
public Stream<BaseClientRepresentation> getClients() {
|
||||||
return clientService.getClients(realm, null, null, null);
|
return clientService.getClients(realm, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Response createClient(@Valid ClientRepresentation client) {
|
public Response createClient(@Valid BaseClientRepresentation client) {
|
||||||
try {
|
try {
|
||||||
DefaultClientApi.validateUnknownFields(client);
|
DefaultClientApi.validateUnknownFields(client);
|
||||||
validator.validate(client, CreateClientDefault.class);
|
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.api.client.ClientApi;
|
||||||
import org.keycloak.admin.client.Keycloak;
|
import org.keycloak.admin.client.Keycloak;
|
||||||
import org.keycloak.common.Profile;
|
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.services.error.ViolationExceptionResponse;
|
||||||
import org.keycloak.testframework.annotations.InjectAdminClient;
|
import org.keycloak.testframework.annotations.InjectAdminClient;
|
||||||
import org.keycloak.testframework.annotations.InjectHttpClient;
|
import org.keycloak.testframework.annotations.InjectHttpClient;
|
||||||
@ -85,7 +85,7 @@ public class ClientApiV2Test {
|
|||||||
setAuthHeader(request);
|
setAuthHeader(request);
|
||||||
try (var response = client.execute(request)) {
|
try (var response = client.execute(request)) {
|
||||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
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());
|
assertEquals("account", client.getClientId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -107,7 +107,7 @@ public class ClientApiV2Test {
|
|||||||
setAuthHeader(request);
|
setAuthHeader(request);
|
||||||
request.setHeader(HttpHeaders.CONTENT_TYPE, ClientApi.CONTENT_TYPE_MERGE_PATCH);
|
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");
|
patch.setDescription("I'm also a description");
|
||||||
|
|
||||||
request.setEntity(new StringEntity(mapper.writeValueAsString(patch)));
|
request.setEntity(new StringEntity(mapper.writeValueAsString(patch)));
|
||||||
@ -115,7 +115,7 @@ public class ClientApiV2Test {
|
|||||||
try (var response = client.execute(request)) {
|
try (var response = client.execute(request)) {
|
||||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
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());
|
assertEquals("I'm also a description", client.getDescription());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -126,7 +126,7 @@ public class ClientApiV2Test {
|
|||||||
setAuthHeader(request);
|
setAuthHeader(request);
|
||||||
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
ClientRepresentation rep = new ClientRepresentation();
|
OIDCClientRepresentation rep = new OIDCClientRepresentation();
|
||||||
rep.setClientId("other");
|
rep.setClientId("other");
|
||||||
|
|
||||||
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
||||||
@ -142,7 +142,7 @@ public class ClientApiV2Test {
|
|||||||
setAuthHeader(request);
|
setAuthHeader(request);
|
||||||
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
ClientRepresentation rep = new ClientRepresentation();
|
OIDCClientRepresentation rep = new OIDCClientRepresentation();
|
||||||
rep.setEnabled(true);
|
rep.setEnabled(true);
|
||||||
rep.setClientId("other");
|
rep.setClientId("other");
|
||||||
rep.setDescription("I'm new");
|
rep.setDescription("I'm new");
|
||||||
@ -151,7 +151,7 @@ public class ClientApiV2Test {
|
|||||||
|
|
||||||
try (var response = client.execute(request)) {
|
try (var response = client.execute(request)) {
|
||||||
assertEquals(201, response.getStatusLine().getStatusCode());
|
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());
|
assertEquals("I'm new", client.getDescription());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,7 +160,7 @@ public class ClientApiV2Test {
|
|||||||
|
|
||||||
try (var response = client.execute(request)) {
|
try (var response = client.execute(request)) {
|
||||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
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());
|
assertEquals("I'm updated", client.getDescription());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -171,7 +171,7 @@ public class ClientApiV2Test {
|
|||||||
setAuthHeader(request);
|
setAuthHeader(request);
|
||||||
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
ClientRepresentation rep = new ClientRepresentation();
|
OIDCClientRepresentation rep = new OIDCClientRepresentation();
|
||||||
rep.setEnabled(true);
|
rep.setEnabled(true);
|
||||||
rep.setClientId("client-123");
|
rep.setClientId("client-123");
|
||||||
rep.setDescription("I'm new");
|
rep.setDescription("I'm new");
|
||||||
@ -180,7 +180,7 @@ public class ClientApiV2Test {
|
|||||||
|
|
||||||
try (var response = client.execute(request)) {
|
try (var response = client.execute(request)) {
|
||||||
assertThat(response.getStatusLine().getStatusCode(),is(201));
|
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.getEnabled(),is(true));
|
||||||
assertThat(client.getClientId(),is("client-123"));
|
assertThat(client.getClientId(),is("client-123"));
|
||||||
assertThat(client.getDescription(),is("I'm new"));
|
assertThat(client.getDescription(),is("I'm new"));
|
||||||
@ -197,7 +197,7 @@ public class ClientApiV2Test {
|
|||||||
setAuthHeader(createRequest);
|
setAuthHeader(createRequest);
|
||||||
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
ClientRepresentation rep = new ClientRepresentation();
|
OIDCClientRepresentation rep = new OIDCClientRepresentation();
|
||||||
rep.setClientId("to-delete");
|
rep.setClientId("to-delete");
|
||||||
rep.setEnabled(true);
|
rep.setEnabled(true);
|
||||||
|
|
||||||
@ -225,13 +225,14 @@ public class ClientApiV2Test {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void clientRepresentationValidation() throws Exception {
|
public void OIDCClientRepresentationValidation() throws Exception {
|
||||||
HttpPost request = new HttpPost(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients");
|
HttpPost request = new HttpPost(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients");
|
||||||
setAuthHeader(request);
|
setAuthHeader(request);
|
||||||
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
request.setEntity(new StringEntity("""
|
request.setEntity(new StringEntity("""
|
||||||
{
|
{
|
||||||
|
"protocol": "openid-connect",
|
||||||
"displayName": "something",
|
"displayName": "something",
|
||||||
"appUrl": "notUrl"
|
"appUrl": "notUrl"
|
||||||
}
|
}
|
||||||
@ -250,6 +251,7 @@ public class ClientApiV2Test {
|
|||||||
|
|
||||||
request.setEntity(new StringEntity("""
|
request.setEntity(new StringEntity("""
|
||||||
{
|
{
|
||||||
|
"protocol": "openid-connect",
|
||||||
"clientId": "some-client",
|
"clientId": "some-client",
|
||||||
"displayName": "something",
|
"displayName": "something",
|
||||||
"appUrl": "notUrl",
|
"appUrl": "notUrl",
|
||||||
@ -285,12 +287,12 @@ public class ClientApiV2Test {
|
|||||||
setAuthHeader(request);
|
setAuthHeader(request);
|
||||||
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
ClientRepresentation rep = getTestingFullClientRep();
|
OIDCClientRepresentation rep = getTestingFullClientRep();
|
||||||
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
||||||
|
|
||||||
try (var response = client.execute(request)) {
|
try (var response = client.execute(request)) {
|
||||||
assertEquals(201, response.getStatusLine().getStatusCode());
|
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));
|
assertThat(client, is(rep));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -301,8 +303,8 @@ public class ClientApiV2Test {
|
|||||||
setAuthHeader(request);
|
setAuthHeader(request);
|
||||||
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
ClientRepresentation rep = getTestingFullClientRep();
|
OIDCClientRepresentation rep = getTestingFullClientRep();
|
||||||
rep.getServiceAccount().setRoles(Set.of("non-existing", "bad-role"));
|
rep.setServiceAccountRoles(Set.of("non-existing", "bad-role"));
|
||||||
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
request.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
||||||
|
|
||||||
try (var response = client.execute(request)) {
|
try (var response = client.execute(request)) {
|
||||||
@ -318,7 +320,7 @@ public class ClientApiV2Test {
|
|||||||
setAuthHeader(createRequest);
|
setAuthHeader(createRequest);
|
||||||
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
ClientRepresentation rep = new ClientRepresentation();
|
OIDCClientRepresentation rep = new OIDCClientRepresentation();
|
||||||
rep.setClientId("declarative-role-test");
|
rep.setClientId("declarative-role-test");
|
||||||
rep.setEnabled(true);
|
rep.setEnabled(true);
|
||||||
rep.setRoles(Set.of("role1", "role2", "role3"));
|
rep.setRoles(Set.of("role1", "role2", "role3"));
|
||||||
@ -327,7 +329,7 @@ public class ClientApiV2Test {
|
|||||||
|
|
||||||
try (var response = client.execute(createRequest)) {
|
try (var response = client.execute(createRequest)) {
|
||||||
assertEquals(201, response.getStatusLine().getStatusCode());
|
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")));
|
assertThat(created.getRoles(), is(Set.of("role1", "role2", "role3")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -341,7 +343,7 @@ public class ClientApiV2Test {
|
|||||||
|
|
||||||
try (var response = client.execute(updateRequest)) {
|
try (var response = client.execute(updateRequest)) {
|
||||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
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")));
|
assertThat(updated.getRoles(), is(Set.of("new-role1", "new-role2")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -351,7 +353,7 @@ public class ClientApiV2Test {
|
|||||||
|
|
||||||
try (var response = client.execute(updateRequest)) {
|
try (var response = client.execute(updateRequest)) {
|
||||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
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")));
|
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)) {
|
try (var response = client.execute(updateRequest)) {
|
||||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
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")));
|
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)) {
|
try (var response = client.execute(updateRequest)) {
|
||||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
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()));
|
assertThat(updated.getRoles(), is(Set.of()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -383,21 +385,19 @@ public class ClientApiV2Test {
|
|||||||
setAuthHeader(createRequest);
|
setAuthHeader(createRequest);
|
||||||
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
createRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
ClientRepresentation rep = new ClientRepresentation();
|
OIDCClientRepresentation rep = new OIDCClientRepresentation();
|
||||||
rep.setClientId("sa-declarative-test");
|
rep.setClientId("sa-declarative-test");
|
||||||
rep.setEnabled(true);
|
rep.setEnabled(true);
|
||||||
|
|
||||||
var serviceAccount = new ClientRepresentation.ServiceAccount();
|
rep.setLoginFlows(Set.of(OIDCClientRepresentation.Flow.SERVICE_ACCOUNT));
|
||||||
serviceAccount.setEnabled(true);
|
rep.setServiceAccountRoles(Set.of("default-roles-master", "offline_access"));
|
||||||
serviceAccount.setRoles(Set.of("default-roles-master", "offline_access"));
|
|
||||||
rep.setServiceAccount(serviceAccount);
|
|
||||||
|
|
||||||
createRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
createRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
||||||
|
|
||||||
try (var response = client.execute(createRequest)) {
|
try (var response = client.execute(createRequest)) {
|
||||||
assertEquals(201, response.getStatusLine().getStatusCode());
|
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.getServiceAccount().getRoles(), is(Set.of("default-roles-master", "offline_access")));
|
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
|
// 2. Update with completely new roles - should remove old ones and add new ones
|
||||||
@ -405,63 +405,55 @@ public class ClientApiV2Test {
|
|||||||
setAuthHeader(updateRequest);
|
setAuthHeader(updateRequest);
|
||||||
updateRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
updateRequest.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
||||||
|
|
||||||
serviceAccount.setRoles(Set.of("uma_authorization", "offline_access"));
|
rep.setServiceAccountRoles(Set.of("uma_authorization", "offline_access"));
|
||||||
rep.setServiceAccount(serviceAccount);
|
|
||||||
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
||||||
|
|
||||||
try (var response = client.execute(updateRequest)) {
|
try (var response = client.execute(updateRequest)) {
|
||||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
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.getServiceAccount().getRoles(), is(Set.of("uma_authorization", "offline_access")));
|
assertThat(updated.getServiceAccountRoles(), is(Set.of("uma_authorization", "offline_access")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Update with partial overlap - keep some, add some, remove some
|
// 3. Update with partial overlap - keep some, add some, remove some
|
||||||
serviceAccount.setRoles(Set.of("offline_access", "default-roles-master"));
|
rep.setServiceAccountRoles(Set.of("offline_access", "default-roles-master"));
|
||||||
rep.setServiceAccount(serviceAccount);
|
|
||||||
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
||||||
|
|
||||||
try (var response = client.execute(updateRequest)) {
|
try (var response = client.execute(updateRequest)) {
|
||||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
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.getServiceAccount().getRoles(), is(Set.of("offline_access", "default-roles-master")));
|
assertThat(updated.getServiceAccountRoles(), is(Set.of("offline_access", "default-roles-master")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Update with same roles - should be idempotent
|
// 4. Update with same roles - should be idempotent
|
||||||
serviceAccount.setRoles(Set.of("offline_access", "default-roles-master"));
|
rep.setServiceAccountRoles(Set.of("offline_access", "default-roles-master"));
|
||||||
rep.setServiceAccount(serviceAccount);
|
|
||||||
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
||||||
|
|
||||||
try (var response = client.execute(updateRequest)) {
|
try (var response = client.execute(updateRequest)) {
|
||||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
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.getServiceAccount().getRoles(), is(Set.of("offline_access", "default-roles-master")));
|
assertThat(updated.getServiceAccountRoles(), is(Set.of("offline_access", "default-roles-master")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Update with empty set - should remove all roles
|
// 5. Update with empty set - should remove all roles
|
||||||
serviceAccount.setRoles(Set.of());
|
rep.setServiceAccountRoles(Set.of());
|
||||||
rep.setServiceAccount(serviceAccount);
|
|
||||||
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
updateRequest.setEntity(new StringEntity(mapper.writeValueAsString(rep)));
|
||||||
|
|
||||||
try (var response = client.execute(updateRequest)) {
|
try (var response = client.execute(updateRequest)) {
|
||||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
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.getServiceAccount().getRoles(), is(Set.of()));
|
assertThat(updated.getServiceAccountRoles(), is(Set.of()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ClientRepresentation getTestingFullClientRep() {
|
private OIDCClientRepresentation getTestingFullClientRep() {
|
||||||
var rep = new ClientRepresentation();
|
var rep = new OIDCClientRepresentation();
|
||||||
rep.setClientId("my-client");
|
rep.setClientId("my-client");
|
||||||
rep.setDisplayName("My Client");
|
rep.setDisplayName("My Client");
|
||||||
rep.setDescription("This is My Client");
|
rep.setDescription("This is My Client");
|
||||||
rep.setProtocol(ClientRepresentation.OIDC);
|
|
||||||
rep.setEnabled(true);
|
rep.setEnabled(true);
|
||||||
rep.setAppUrl("http://localhost:3000");
|
rep.setAppUrl("http://localhost:3000");
|
||||||
rep.setAppRedirectUrls(Set.of("http://localhost:3000", "http://localhost:3001"));
|
rep.setRedirectUris(Set.of("http://localhost:3000", "http://localhost:3001"));
|
||||||
// no login flows -> only flow overrides map
|
var auth = new OIDCClientRepresentation.Auth();
|
||||||
// rep.setLoginFlows(Set.of("browser"));
|
|
||||||
var auth = new ClientRepresentation.Auth();
|
|
||||||
auth.setEnabled(true);
|
|
||||||
auth.setMethod("client-jwt");
|
auth.setMethod("client-jwt");
|
||||||
auth.setSecret("secret-1234");
|
auth.setSecret("secret-1234");
|
||||||
// no certificate inside the old rep
|
// no certificate inside the old rep
|
||||||
@ -469,11 +461,9 @@ public class ClientApiV2Test {
|
|||||||
rep.setAuth(auth);
|
rep.setAuth(auth);
|
||||||
rep.setWebOrigins(Set.of("http://localhost:4000", "http://localhost:4001"));
|
rep.setWebOrigins(Set.of("http://localhost:4000", "http://localhost:4001"));
|
||||||
rep.setRoles(Set.of("view-consent", "manage-account"));
|
rep.setRoles(Set.of("view-consent", "manage-account"));
|
||||||
var serviceAccount = new ClientRepresentation.ServiceAccount();
|
rep.setLoginFlows(Set.of(OIDCClientRepresentation.Flow.SERVICE_ACCOUNT));
|
||||||
serviceAccount.setEnabled(true);
|
|
||||||
// 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
|
// 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.setServiceAccountRoles(Set.of("default-roles-master"));
|
||||||
rep.setServiceAccount(serviceAccount);
|
|
||||||
// not implemented yet
|
// not implemented yet
|
||||||
// rep.setAdditionalFields(Map.of("key1", "val1", "key2", "val2"));
|
// rep.setAdditionalFields(Map.of("key1", "val1", "key2", "val2"));
|
||||||
return rep;
|
return rep;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user