mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-21 06:20:05 -06:00
Initial Client API v2 impl (#43395)
Closes #43224 Signed-off-by: Václav Muzikář <vmuzikar@redhat.com> Co-authored-by: Martin Bartoš <mabartos@redhat.com> Co-authored-by: Peter Zaoral <pzaoral@redhat.com> Co-authored-by: Steven Hawkins <shawkins@redhat.com> Co-authored-by: Robin Meese <39960884+robson90@users.noreply.github.com>
This commit is contained in:
3
.github/actions/conditional/action.yml
vendored
3
.github/actions/conditional/action.yml
vendored
@@ -49,6 +49,9 @@ outputs:
|
||||
sssd:
|
||||
description: Should "sssd.yml" execute
|
||||
value: ${{ steps.changes.outputs.sssd }}
|
||||
admin-v2:
|
||||
description: Should Admin v2 tests execute
|
||||
value: ${{ steps.changes.outputs.admin-v2 }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
|
||||
2
.github/actions/conditional/conditions
vendored
2
.github/actions/conditional/conditions
vendored
@@ -59,3 +59,5 @@ js/libs/ui-shared/ ci ci-webauthn
|
||||
*.tsx codeql-typescript
|
||||
|
||||
testsuite::database-suite ci-store
|
||||
|
||||
rest/admin-v2/ admin-v2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
find . -path '**/src/test/java' -type d \
|
||||
| grep -v -E '\./(docs|distribution|misc|operator|tests|testsuite|test-framework|quarkus)/' \
|
||||
| grep -v -E '\./(docs|distribution|misc|operator|((.+/)?tests)|testsuite|test-framework|quarkus)/' \
|
||||
| sed 's|/src/test/java||' \
|
||||
| sed 's|./||' \
|
||||
| sort \
|
||||
|
||||
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -39,6 +39,7 @@ jobs:
|
||||
ci-aurora: ${{ steps.auroradb-tests.outputs.run-aurora-tests }}
|
||||
ci-compatibility-matrix: ${{ steps.version-compatibility.outputs.matrix }}
|
||||
ci-additional-dbs: ${{ steps.additional-dbs-tests.outputs.run-additional-dbs-tests }}
|
||||
ci-admin-v2: ${{ steps.conditional.outputs.admin-v2 }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -897,6 +898,24 @@ jobs:
|
||||
- name: Run tests
|
||||
run: ./mvnw package -f tests/pom.xml
|
||||
|
||||
admin-v2-tests:
|
||||
name: Admin v2
|
||||
if: needs.conditional.outputs.ci-admin-v2 == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
- conditional
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- id: integration-test-setup
|
||||
name: Integration test setup
|
||||
uses: ./.github/actions/integration-test-setup
|
||||
|
||||
- name: Run tests
|
||||
run: ./mvnw verify -pl rest/admin-v2/tests/pom.xml
|
||||
|
||||
mixed-cluster-compatibility-tests:
|
||||
name: Cluster Compatibility Tests
|
||||
if: needs.conditional.outputs.ci-compatibility-matrix != 'skip'
|
||||
@@ -954,6 +973,7 @@ jobs:
|
||||
- test-framework
|
||||
- base-new-integration-tests
|
||||
- mixed-cluster-compatibility-tests
|
||||
- admin-v2-tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
@@ -59,6 +59,8 @@ public class Profile {
|
||||
|
||||
ADMIN_API("Admin API", Type.DEFAULT),
|
||||
|
||||
CLIENT_ADMIN_API_V2("Client Admin API v2", Type.EXPERIMENTAL, 2, Feature.ADMIN_API),
|
||||
|
||||
ADMIN_V2("New Admin Console", Type.DEFAULT, 2, Feature.ADMIN_API),
|
||||
|
||||
LOGIN_V2("New Login Theme", Type.DEFAULT, 2, FeatureUpdatePolicy.ROLLING_NO_UPGRADE),
|
||||
@@ -152,6 +154,8 @@ public class Profile {
|
||||
|
||||
HTTP_OPTIMIZED_SERIALIZERS("Optimized JSON serializers for better performance of the HTTP layer", Type.PREVIEW),
|
||||
|
||||
OPENAPI("OpenAPI specification served at runtime", Type.EXPERIMENTAL, CLIENT_ADMIN_API_V2),
|
||||
|
||||
/**
|
||||
* @see <a href="https://github.com/keycloak/keycloak/issues/37967">Deprecate for removal the Instagram social broker</a>.
|
||||
*/
|
||||
|
||||
28
pom.xml
28
pom.xml
@@ -92,6 +92,7 @@
|
||||
<hibernate-orm.plugin.version>6.2.13.Final</hibernate-orm.plugin.version>
|
||||
<hibernate.c3p0.version>6.2.13.Final</hibernate.c3p0.version>
|
||||
<infinispan.version>15.0.19.Final</infinispan.version>
|
||||
<hibernate-validator.version>9.0.1.Final</hibernate-validator.version>
|
||||
<protostream.version>5.0.14.Final</protostream.version> <!-- For the annotation processor: keep in sync with the version shipped with Infinispan -->
|
||||
<protostream.plugin.version>${protostream.version}</protostream.plugin.version>
|
||||
|
||||
@@ -228,6 +229,8 @@
|
||||
<!-- Used to test SAML Galleon feature-pack layers discovery -->
|
||||
<version.org.wildfly.glow>1.0.0.Alpha8</version.org.wildfly.glow>
|
||||
|
||||
<org.mapstruct.version>1.6.3</org.mapstruct.version>
|
||||
|
||||
<!-- Galleon -->
|
||||
<galleon.fork.embedded>true</galleon.fork.embedded>
|
||||
<galleon.log.time>true</galleon.log.time>
|
||||
@@ -399,6 +402,11 @@
|
||||
<artifactId>xsom</artifactId>
|
||||
<version>${org.glassfish.jaxb.xsom.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct</artifactId>
|
||||
<version>${org.mapstruct.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
@@ -591,6 +599,11 @@
|
||||
<artifactId>h2</artifactId>
|
||||
<version>${h2.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hibernate.validator</groupId>
|
||||
<artifactId>hibernate-validator</artifactId>
|
||||
<version>${hibernate-validator.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hibernate.orm</groupId>
|
||||
<artifactId>hibernate-c3p0</artifactId>
|
||||
@@ -1127,6 +1140,21 @@
|
||||
<artifactId>keycloak-rest-admin-ui-ext</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-admin-v2-api</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-admin-v2-providers</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-admin-v2-rest</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-saml-wildfly-modules</artifactId>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.keycloak.config;
|
||||
|
||||
public class OpenApiOptions {
|
||||
|
||||
public static final Option<Boolean> OPENAPI_ENABLED = new OptionBuilder<>("openapi-enabled", Boolean.class)
|
||||
.category(OptionCategory.OPENAPI)
|
||||
.description("If the server should expose OpenAPI Endpoint. If enabled, OpenAPI is available at '/openapi'.")
|
||||
.buildTime(true)
|
||||
.defaultValue(Boolean.FALSE)
|
||||
.build();
|
||||
public static final Option<Boolean> OPENAPI_UI_ENABLED = new OptionBuilder<>("openapi-ui-enabled", Boolean.class)
|
||||
.category(OptionCategory.OPENAPI)
|
||||
.description("If the server should expose OpenApi-UI Endpoint. If enabled, OpenAPI UI is available at '/openapi/ui'.")
|
||||
.buildTime(true)
|
||||
.defaultValue(Boolean.FALSE)
|
||||
.build();
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public enum OptionCategory {
|
||||
SECURITY("Security", 120, ConfigSupportLevel.SUPPORTED),
|
||||
EXPORT("Export", 130, ConfigSupportLevel.SUPPORTED),
|
||||
IMPORT("Import", 140, ConfigSupportLevel.SUPPORTED),
|
||||
OPENAPI("OpenAPI configuration", 150, ConfigSupportLevel.SUPPORTED),
|
||||
BOOTSTRAP_ADMIN("Bootstrap Admin", 998, ConfigSupportLevel.SUPPORTED),
|
||||
GENERAL("General", 999, ConfigSupportLevel.SUPPORTED);
|
||||
|
||||
|
||||
@@ -138,6 +138,10 @@
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-rest-jackson-deployment</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-hibernate-validator-deployment</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-hibernate-orm-deployment</artifactId>
|
||||
@@ -236,6 +240,14 @@
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-smallrye-health-deployment</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-smallrye-openapi-deployment</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-swagger-ui-deployment</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-micrometer-deployment</artifactId>
|
||||
|
||||
@@ -135,12 +135,31 @@
|
||||
<artifactId>rdf-urdna</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct</artifactId>
|
||||
<version>${org.mapstruct.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Hibernate validator -->
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-hibernate-validator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- SmallRye -->
|
||||
<dependency>
|
||||
<groupId>io.smallrye.config</groupId>
|
||||
<artifactId>smallrye-config-source-keystore</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-smallrye-openapi</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-swagger-ui</artifactId>
|
||||
</dependency>
|
||||
<!-- CLI -->
|
||||
<dependency>
|
||||
<groupId>info.picocli</groupId>
|
||||
@@ -394,7 +413,44 @@
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-admin-v2-api</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>*</groupId>
|
||||
<artifactId>*</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-admin-v2-providers</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>*</groupId>
|
||||
<artifactId>*</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-admin-v2-rest</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>*</groupId>
|
||||
<artifactId>*</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- Keycloak Dependencies-->
|
||||
<dependency>
|
||||
<groupId>io.fabric8</groupId>
|
||||
<artifactId>zjsonpatch</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jboss.logging</groupId>
|
||||
<artifactId>commons-logging-jboss-logging</artifactId>
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.keycloak.config.HttpOptions;
|
||||
import org.keycloak.config.ManagementOptions;
|
||||
import org.keycloak.config.ManagementOptions.Scheme;
|
||||
import org.keycloak.config.MetricsOptions;
|
||||
import org.keycloak.config.OpenApiOptions;
|
||||
import org.keycloak.quarkus.runtime.configuration.Configuration;
|
||||
|
||||
import static org.keycloak.config.ManagementOptions.LEGACY_OBSERVABILITY_INTERFACE;
|
||||
@@ -126,9 +127,9 @@ public class ManagementPropertyMappers implements PropertyMapperGrouping {
|
||||
if (isTrue(LEGACY_OBSERVABILITY_INTERFACE)) {
|
||||
return false;
|
||||
}
|
||||
var isManagementOccupied = isTrue(MetricsOptions.METRICS_ENABLED)
|
||||
|| (isTrue(HealthOptions.HEALTH_ENABLED) && isTrue(ManagementOptions.HTTP_MANAGEMENT_HEALTH_ENABLED));
|
||||
return isManagementOccupied;
|
||||
return (isTrue(HealthOptions.HEALTH_ENABLED) && isTrue(ManagementOptions.HTTP_MANAGEMENT_HEALTH_ENABLED))
|
||||
|| isTrue(MetricsOptions.METRICS_ENABLED)
|
||||
|| isTrue(OpenApiOptions.OPENAPI_ENABLED);
|
||||
}
|
||||
|
||||
private static String managementEnabledTransformer() {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.keycloak.quarkus.runtime.configuration.mappers;
|
||||
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.config.OpenApiOptions;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.keycloak.quarkus.runtime.configuration.Configuration.isTrue;
|
||||
import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromOption;
|
||||
|
||||
public final class OpenApiPropertyMappers implements PropertyMapperGrouping {
|
||||
|
||||
@Override
|
||||
public List<? extends PropertyMapper<?>> getPropertyMappers() {
|
||||
return List.of(
|
||||
fromOption(OpenApiOptions.OPENAPI_ENABLED)
|
||||
.isEnabled(OpenApiPropertyMappers::isClientApiEnabled, "OpenAPI feature is enabled")
|
||||
.to("quarkus.smallrye-openapi.enable")
|
||||
.build(),
|
||||
fromOption(OpenApiOptions.OPENAPI_UI_ENABLED)
|
||||
.isEnabled(OpenApiPropertyMappers::isOpenApiEnabled, "OpenAPI Endpoint is enabled")
|
||||
.to("quarkus.swagger-ui.enable")
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
private static boolean isOpenApiEnabled() {
|
||||
return isTrue(OpenApiOptions.OPENAPI_ENABLED);
|
||||
}
|
||||
|
||||
private static boolean isClientApiEnabled() {
|
||||
return Profile.isFeatureEnabled(Profile.Feature.OPENAPI);
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ public final class PropertyMappers {
|
||||
new ExportPropertyMappers(), new BootstrapAdminPropertyMappers(), new HostnameV2PropertyMappers(),
|
||||
new HttpPropertyMappers(), new HttpAccessLogPropertyMappers(), new HealthPropertyMappers(),
|
||||
new FeaturePropertyMappers(), new ImportPropertyMappers(), new ManagementPropertyMappers(),
|
||||
new MetricsPropertyMappers(), new LoggingPropertyMappers(), new ProxyPropertyMappers(),
|
||||
new MetricsPropertyMappers(), new OpenApiPropertyMappers(), new LoggingPropertyMappers(), new ProxyPropertyMappers(),
|
||||
new VaultPropertyMappers(), new TracingPropertyMappers(), new TransactionPropertyMappers(),
|
||||
new SecurityPropertyMappers(), new TruststorePropertyMappers());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package org.keycloak.quarkus.runtime.oas;
|
||||
|
||||
import io.quarkus.smallrye.openapi.OpenApiFilter;
|
||||
import org.eclipse.microprofile.openapi.OASFactory;
|
||||
import org.eclipse.microprofile.openapi.OASFilter;
|
||||
import org.eclipse.microprofile.openapi.models.OpenAPI;
|
||||
import org.eclipse.microprofile.openapi.models.Operation;
|
||||
import org.eclipse.microprofile.openapi.models.PathItem;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@OpenApiFilter(OpenApiFilter.RunStage.BUILD)
|
||||
public class OASModelFilter implements OASFilter {
|
||||
|
||||
@Override
|
||||
public void filterOpenAPI(OpenAPI openAPI) {
|
||||
// Filter Paths that have the '/admin/api/v2' prefix
|
||||
Map<String, PathItem> newPaths = openAPI.getPaths().getPathItems().entrySet().stream()
|
||||
.filter(entry -> entry.getKey().startsWith("/admin/api/v2"))
|
||||
.collect(Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
entry -> sortOperationsByMethod(entry.getValue())
|
||||
));
|
||||
|
||||
// Replace ALL Paths with filtered Paths
|
||||
var paths = OASFactory.createPaths();
|
||||
newPaths.forEach(paths::addPathItem);
|
||||
openAPI.setPaths(paths);
|
||||
|
||||
// Compute tags that are actually used by remaining operations
|
||||
Set<String> usedTags = newPaths.values().stream()
|
||||
.flatMap(pi -> operationsOf(pi).stream())
|
||||
.flatMap(op -> Optional.ofNullable(op.getTags()).orElseGet(List::of).stream())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Drop top-level tags not used anywhere
|
||||
if (openAPI.getTags() != null) {
|
||||
var filteredTags = openAPI.getTags().stream()
|
||||
.filter(t -> t.getName() != null && usedTags.contains(t.getName()))
|
||||
.collect(Collectors.toList());
|
||||
openAPI.setTags(filteredTags.isEmpty() ? null : filteredTags);
|
||||
}
|
||||
}
|
||||
|
||||
private PathItem sortOperationsByMethod(PathItem pathItem) {
|
||||
PathItem sortedPathItem = OASFactory.createPathItem();
|
||||
|
||||
// Add operations order: GET -> POST -> PUT -> PATCH -> DELETE -> HEAD -> OPTIONS -> TRACE
|
||||
if (pathItem.getGET() != null) {
|
||||
sortedPathItem.setGET(pathItem.getGET());
|
||||
}
|
||||
if (pathItem.getPOST() != null) {
|
||||
sortedPathItem.setPOST(pathItem.getPOST());
|
||||
}
|
||||
if (pathItem.getPUT() != null) {
|
||||
sortedPathItem.setPUT(pathItem.getPUT());
|
||||
}
|
||||
if (pathItem.getPATCH() != null) {
|
||||
sortedPathItem.setPATCH(pathItem.getPATCH());
|
||||
}
|
||||
if (pathItem.getDELETE() != null) {
|
||||
sortedPathItem.setDELETE(pathItem.getDELETE());
|
||||
}
|
||||
if (pathItem.getHEAD() != null) {
|
||||
sortedPathItem.setHEAD(pathItem.getHEAD());
|
||||
}
|
||||
if (pathItem.getOPTIONS() != null) {
|
||||
sortedPathItem.setOPTIONS(pathItem.getOPTIONS());
|
||||
}
|
||||
if (pathItem.getTRACE() != null) {
|
||||
sortedPathItem.setTRACE(pathItem.getTRACE());
|
||||
}
|
||||
|
||||
sortedPathItem.setSummary(pathItem.getSummary());
|
||||
sortedPathItem.setDescription(pathItem.getDescription());
|
||||
sortedPathItem.setServers(pathItem.getServers());
|
||||
sortedPathItem.setParameters(pathItem.getParameters());
|
||||
|
||||
return sortedPathItem;
|
||||
}
|
||||
|
||||
private List<Operation> operationsOf(PathItem pi) {
|
||||
List<Operation> ops = new ArrayList<>(8);
|
||||
if (pi.getGET() != null) ops.add(pi.getGET());
|
||||
if (pi.getPOST() != null) ops.add(pi.getPOST());
|
||||
if (pi.getPUT() != null) ops.add(pi.getPUT());
|
||||
if (pi.getPATCH() != null) ops.add(pi.getPATCH());
|
||||
if (pi.getDELETE() != null) ops.add(pi.getDELETE());
|
||||
if (pi.getHEAD() != null) ops.add(pi.getHEAD());
|
||||
if (pi.getOPTIONS() != null) ops.add(pi.getOPTIONS());
|
||||
if (pi.getTRACE() != null) ops.add(pi.getTRACE());
|
||||
return ops;
|
||||
}
|
||||
}
|
||||
@@ -92,3 +92,16 @@ quarkus.http.limits.max-header-size=65535
|
||||
#logging defaults
|
||||
kc.log-console-output=default
|
||||
kc.log-file=${kc.home.dir:default}${file.separator}data${file.separator}log${file.separator}keycloak.log
|
||||
|
||||
#OpenAPI defaults
|
||||
quarkus.smallrye-openapi.path=/openapi
|
||||
quarkus.smallrye-openapi.store-schema-directory=${openapi.schema.target}
|
||||
quarkus.swagger-ui.path=${quarkus.smallrye-openapi.path}/ui
|
||||
quarkus.swagger-ui.always-include=true
|
||||
quarkus.swagger-ui.filter=true
|
||||
mp.openapi.filter=org.keycloak.quarkus.runtime.oas.OASModelFilter
|
||||
mp.openapi.extensions.smallrye.remove-unused-schemas.enable=true
|
||||
|
||||
# Disable Error messages from smallrye.openapi
|
||||
# related issue: https://github.com/keycloak/keycloak/issues/41871
|
||||
quarkus.log.category."io.smallrye.openapi.runtime.scanner.dataobject".level=off
|
||||
@@ -40,6 +40,10 @@
|
||||
<artifactId>importmap</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-smallrye-openapi</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<properties>
|
||||
@@ -70,6 +74,7 @@
|
||||
<kc.home.dir>${kc.home.dir}</kc.home.dir>
|
||||
<kc.db>dev-file</kc.db>
|
||||
<java.util.concurrent.ForkJoinPool.common.threadFactory>io.quarkus.bootstrap.forkjoin.QuarkusForkJoinWorkerThreadFactory</java.util.concurrent.ForkJoinPool.common.threadFactory>
|
||||
<openapi.schema.target>${project.build.directory}</openapi.schema.target>
|
||||
</systemProperties>
|
||||
</configuration>
|
||||
<executions>
|
||||
|
||||
89
quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/OpenApiDistTest.java
vendored
Normal file
89
quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/OpenApiDistTest.java
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2021 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.it.cli.dist;
|
||||
|
||||
import io.quarkus.test.junit.main.Launch;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.keycloak.it.junit5.extension.CLIResult;
|
||||
import org.keycloak.it.junit5.extension.DistributionTest;
|
||||
import org.keycloak.it.junit5.extension.DryRun;
|
||||
import org.keycloak.it.utils.KeycloakDistribution;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static io.restassured.RestAssured.when;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
@DistributionTest(keepAlive = true, requestPort = 9000, containerExposedPorts = {8080, 9000})
|
||||
@Tag(DistributionTest.SLOW)
|
||||
public class OpenApiDistTest {
|
||||
|
||||
private static final String OPENAPI_ENDPOINT = "/openapi";
|
||||
private static final String OPENAPI_UI_ENDPOINT = "/openapi/ui";
|
||||
|
||||
// Cannot use defaultOptions as we want to test it being absent too
|
||||
private static final String FEATURES_OPTION = "--features=openapi,client-admin-api:v2";
|
||||
|
||||
@Test
|
||||
@Launch({"start-dev", FEATURES_OPTION})
|
||||
void testOpenApiEndpointNotEnabled(KeycloakDistribution distribution) {
|
||||
assertThrows(IOException.class, () -> when().get(OPENAPI_ENDPOINT), "Connection refused must be thrown");
|
||||
assertThrows(IOException.class, () -> when().get(OPENAPI_UI_ENDPOINT), "Connection refused must be thrown");
|
||||
|
||||
distribution.setRequestPort(8080);
|
||||
|
||||
when().get(OPENAPI_ENDPOINT).then()
|
||||
.statusCode(404);
|
||||
when().get(OPENAPI_UI_ENDPOINT).then()
|
||||
.statusCode(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Launch({"start-dev", "--openapi-enabled=true", FEATURES_OPTION})
|
||||
void testOpenApiEndpointEnabled(KeycloakDistribution distribution) {
|
||||
when().get(OPENAPI_ENDPOINT)
|
||||
.then()
|
||||
.statusCode(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Launch({"start-dev", "--openapi-ui-enabled=true", "--openapi-enabled=true", FEATURES_OPTION})
|
||||
void testOpenApiUiEndpointEnabled(KeycloakDistribution distribution) {
|
||||
when().get(OPENAPI_UI_ENDPOINT)
|
||||
.then()
|
||||
.statusCode(200);
|
||||
}
|
||||
|
||||
@DryRun
|
||||
@Test
|
||||
@Launch({ "start-dev", "--openapi-ui-enabled=true", FEATURES_OPTION})
|
||||
void testOpenApiUiFailsWhenOpenApiIsNotEnabled(CLIResult cliResult) {
|
||||
cliResult.assertError("Disabled option: '--openapi-ui-enabled'. Available only when OpenAPI Endpoint is enabled");
|
||||
}
|
||||
|
||||
@DryRun
|
||||
@Test
|
||||
void testOpenApiRequiresFeatures(KeycloakDistribution dist) {
|
||||
CLIResult cliResult = dist.run("start-dev", "--openapi-enabled=true", "--features=openapi");
|
||||
cliResult.assertError("ERROR: Feature openapi depends on disabled feature 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");
|
||||
}
|
||||
}
|
||||
@@ -465,6 +465,17 @@ Export:
|
||||
Set the number of users per file. It is used only if 'users' is set to
|
||||
'different_files'. Default: 50.
|
||||
|
||||
OpenAPI configuration:
|
||||
|
||||
--openapi-enabled <true|false>
|
||||
If the server should expose OpenAPI Endpoint. If enabled, OpenAPI is available
|
||||
at '/openapi'. Default: false. Available only when OpenAPI feature is
|
||||
enabled.
|
||||
--openapi-ui-enabled <true|false>
|
||||
If the server should expose OpenApi-UI Endpoint. If enabled, OpenAPI UI is
|
||||
available at '/openapi/ui'. Default: false. Available only when OpenAPI
|
||||
Endpoint is enabled.
|
||||
|
||||
Bootstrap Admin:
|
||||
|
||||
--bootstrap-admin-client-id <client id>
|
||||
|
||||
@@ -460,6 +460,17 @@ Import:
|
||||
Set if existing data should be overwritten. If set to false, data will be
|
||||
ignored. Default: true.
|
||||
|
||||
OpenAPI configuration:
|
||||
|
||||
--openapi-enabled <true|false>
|
||||
If the server should expose OpenAPI Endpoint. If enabled, OpenAPI is available
|
||||
at '/openapi'. Default: false. Available only when OpenAPI feature is
|
||||
enabled.
|
||||
--openapi-ui-enabled <true|false>
|
||||
If the server should expose OpenApi-UI Endpoint. If enabled, OpenAPI UI is
|
||||
available at '/openapi/ui'. Default: false. Available only when OpenAPI
|
||||
Endpoint is enabled.
|
||||
|
||||
Bootstrap Admin:
|
||||
|
||||
--bootstrap-admin-client-id <client id>
|
||||
|
||||
@@ -708,6 +708,17 @@ Security:
|
||||
feature is enabled. Possible values are: non-strict, strict. Default:
|
||||
disabled.
|
||||
|
||||
OpenAPI configuration:
|
||||
|
||||
--openapi-enabled <true|false>
|
||||
If the server should expose OpenAPI Endpoint. If enabled, OpenAPI is available
|
||||
at '/openapi'. Default: false. Available only when OpenAPI feature is
|
||||
enabled.
|
||||
--openapi-ui-enabled <true|false>
|
||||
If the server should expose OpenApi-UI Endpoint. If enabled, OpenAPI UI is
|
||||
available at '/openapi/ui'. Default: false. Available only when OpenAPI
|
||||
Endpoint is enabled.
|
||||
|
||||
Bootstrap Admin:
|
||||
|
||||
--bootstrap-admin-client-id <client id>
|
||||
|
||||
@@ -709,6 +709,17 @@ Security:
|
||||
feature is enabled. Possible values are: non-strict, strict. Default:
|
||||
disabled.
|
||||
|
||||
OpenAPI configuration:
|
||||
|
||||
--openapi-enabled <true|false>
|
||||
If the server should expose OpenAPI Endpoint. If enabled, OpenAPI is available
|
||||
at '/openapi'. Default: false. Available only when OpenAPI feature is
|
||||
enabled.
|
||||
--openapi-ui-enabled <true|false>
|
||||
If the server should expose OpenApi-UI Endpoint. If enabled, OpenAPI UI is
|
||||
available at '/openapi/ui'. Default: false. Available only when OpenAPI
|
||||
Endpoint is enabled.
|
||||
|
||||
Bootstrap Admin:
|
||||
|
||||
--bootstrap-admin-client-id <client id>
|
||||
|
||||
@@ -708,6 +708,17 @@ Security:
|
||||
feature is enabled. Possible values are: non-strict, strict. Default:
|
||||
disabled.
|
||||
|
||||
OpenAPI configuration:
|
||||
|
||||
--openapi-enabled <true|false>
|
||||
If the server should expose OpenAPI Endpoint. If enabled, OpenAPI is available
|
||||
at '/openapi'. Default: false. Available only when OpenAPI feature is
|
||||
enabled.
|
||||
--openapi-ui-enabled <true|false>
|
||||
If the server should expose OpenApi-UI Endpoint. If enabled, OpenAPI UI is
|
||||
available at '/openapi/ui'. Default: false. Available only when OpenAPI
|
||||
Endpoint is enabled.
|
||||
|
||||
Bootstrap Admin:
|
||||
|
||||
--bootstrap-admin-client-id <client id>
|
||||
|
||||
@@ -706,6 +706,17 @@ Security:
|
||||
feature is enabled. Possible values are: non-strict, strict. Default:
|
||||
disabled.
|
||||
|
||||
OpenAPI configuration:
|
||||
|
||||
--openapi-enabled <true|false>
|
||||
If the server should expose OpenAPI Endpoint. If enabled, OpenAPI is available
|
||||
at '/openapi'. Default: false. Available only when OpenAPI feature is
|
||||
enabled.
|
||||
--openapi-ui-enabled <true|false>
|
||||
If the server should expose OpenApi-UI Endpoint. If enabled, OpenAPI UI is
|
||||
available at '/openapi/ui'. Default: false. Available only when OpenAPI
|
||||
Endpoint is enabled.
|
||||
|
||||
Bootstrap Admin:
|
||||
|
||||
--bootstrap-admin-client-id <client id>
|
||||
|
||||
44
rest/admin-v2/api/pom.xml
Normal file
44
rest/admin-v2/api/pom.xml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-admin-v2-parent</artifactId>
|
||||
<version>999.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>keycloak-admin-v2-api</artifactId>
|
||||
<name>Keycloak Admin API v2 Interfaces</name>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<maven.compiler.release>17</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hibernate.validator</groupId>
|
||||
<artifactId>hibernate-validator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.ws.rs</groupId>
|
||||
<artifactId>jakarta.ws.rs-api</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
|
||||
public interface ClientModelMapper {
|
||||
|
||||
ClientRepresentation fromModel(ClientModel model);
|
||||
|
||||
void toModel(ClientModel model, ClientRepresentation rep, RealmModel realm);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
import org.keycloak.provider.Provider;
|
||||
|
||||
public interface ModelMapper extends Provider {
|
||||
|
||||
ClientModelMapper clients();
|
||||
|
||||
default void close() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
public interface ModelMapperFactory extends ProviderFactory<ModelMapper> {
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.provider.Spi;
|
||||
|
||||
public class ModelMapperSpi implements Spi {
|
||||
public static final String NAME = "model-mapper";
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ModelMapper> getProviderClass() {
|
||||
return ModelMapper.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory<ModelMapper>> getProviderFactoryClass() {
|
||||
return ModelMapperFactory.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
// Currently used only by Client Admin API v2
|
||||
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.keycloak.representations.admin.v2;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAnyGetter;
|
||||
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||
public class BaseRepresentation {
|
||||
|
||||
@JsonIgnore
|
||||
protected Map<String, Object> additionalFields = new LinkedHashMap<String, Object>();
|
||||
|
||||
@JsonAnyGetter
|
||||
public Map<String, Object> getAdditionalFields() {
|
||||
return additionalFields;
|
||||
}
|
||||
|
||||
@JsonAnySetter
|
||||
public void setAdditionalField(String name, Object value) {
|
||||
this.additionalFields.put(name, value);
|
||||
}
|
||||
|
||||
public void setAdditionalFields(Map<String, Object> additionalFields) {
|
||||
this.additionalFields = additionalFields;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package org.keycloak.representations.admin.v2;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.hibernate.validator.constraints.URL;
|
||||
import org.keycloak.representations.admin.v2.validation.CreateClient;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
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 {
|
||||
|
||||
@NotNull
|
||||
@JsonPropertyDescription("Whether authentication is enabled for this client")
|
||||
private Boolean enabled;
|
||||
|
||||
@JsonPropertyDescription("Which authentication method is used for this client")
|
||||
private String method;
|
||||
|
||||
@JsonPropertyDescription("Secret used to authenticate this client with Secret authentication")
|
||||
private String secret;
|
||||
|
||||
@JsonPropertyDescription("Public key used to authenticate this client with Signed JWT authentication")
|
||||
private String certificate;
|
||||
|
||||
public Boolean getEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getMethod() {
|
||||
return method;
|
||||
}
|
||||
|
||||
public void setMethod(String method) {
|
||||
this.method = method;
|
||||
}
|
||||
|
||||
public String getSecret() {
|
||||
return secret;
|
||||
}
|
||||
|
||||
public void setSecret(String secret) {
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
public String getCertificate() {
|
||||
return certificate;
|
||||
}
|
||||
|
||||
public void setCertificate(String certificate) {
|
||||
this.certificate = certificate;
|
||||
}
|
||||
}
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||
public static class ServiceAccount {
|
||||
|
||||
@NotNull
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.keycloak.representations.admin.v2.validation;
|
||||
|
||||
// Jakarta Validation Group - validation is done only when creating a client
|
||||
public interface CreateClient {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.keycloak.representations.admin.v2.validation;
|
||||
|
||||
import jakarta.validation.GroupSequence;
|
||||
import jakarta.validation.groups.Default;
|
||||
|
||||
@GroupSequence({CreateClient.class, Default.class})
|
||||
// Jakarta Validation Group - validation is done only when creating a client + default group included
|
||||
public interface CreateClientDefault {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.keycloak.services;
|
||||
|
||||
import org.keycloak.provider.Provider;
|
||||
|
||||
/**
|
||||
* Service handling business logic for various user interfaces (REST API, GraphQL, GitOps,...)
|
||||
*/
|
||||
public interface Service extends Provider {
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.keycloak.services;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class ServiceException extends RuntimeException {
|
||||
private Response.Status suggestedHttpResponseStatus;
|
||||
|
||||
public ServiceException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ServiceException(String message, Throwable throwable) {
|
||||
super(message, throwable);
|
||||
}
|
||||
|
||||
public ServiceException(String message, Response.Status suggestedStatus) {
|
||||
this(message);
|
||||
this.suggestedHttpResponseStatus = suggestedStatus;
|
||||
}
|
||||
|
||||
public Optional<Response.Status> getSuggestedResponseStatus() {
|
||||
return Optional.ofNullable(suggestedHttpResponseStatus);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.keycloak.services.client;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
import org.keycloak.services.Service;
|
||||
import org.keycloak.services.ServiceException;
|
||||
|
||||
public interface ClientService extends Service {
|
||||
|
||||
class ClientSearchOptions {
|
||||
// TODO
|
||||
}
|
||||
|
||||
class ClientProjectionOptions {
|
||||
// TODO
|
||||
}
|
||||
|
||||
class ClientSortAndSliceOptions {
|
||||
// order by
|
||||
// offset
|
||||
// limit
|
||||
// NOTE: this is not always the most desirable way to do pagination
|
||||
}
|
||||
|
||||
record CreateOrUpdateResult(ClientRepresentation representation, boolean created) {}
|
||||
|
||||
Optional<ClientRepresentation> getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions);
|
||||
|
||||
Stream<ClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions, ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions);
|
||||
|
||||
ClientRepresentation deleteClient(RealmModel realm, String clientId);
|
||||
|
||||
Stream<ClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions);
|
||||
|
||||
CreateOrUpdateResult createOrUpdate(RealmModel realm, ClientRepresentation client, boolean allowUpdate) throws ServiceException;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.keycloak.services.client;
|
||||
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
public interface ClientServiceFactory extends ProviderFactory<ClientService> {
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.keycloak.services.client;
|
||||
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.provider.Spi;
|
||||
|
||||
public class ClientServiceSpi implements Spi {
|
||||
public static final String NAME = "client-service";
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ClientService> getProviderClass() {
|
||||
return ClientService.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory<ClientService>> getProviderFactoryClass() {
|
||||
return ClientServiceFactory.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.keycloak.services.error;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public record ViolationExceptionResponse(String error, Set<String> violations) {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.keycloak.validation.jakarta;
|
||||
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import jakarta.validation.Validator;
|
||||
import org.keycloak.provider.Provider;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
public interface JakartaValidatorProvider extends Provider {
|
||||
|
||||
<T> void validate(T object, Class<?>... groups) throws ConstraintViolationException;
|
||||
|
||||
void validate(Function<Validator, Set<ConstraintViolation<?>>> validation) throws ConstraintViolationException;
|
||||
|
||||
Validator getValidator();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.keycloak.validation.jakarta;
|
||||
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
public interface JakartaValidatorProviderFactory extends ProviderFactory<JakartaValidatorProvider> {
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.keycloak.validation.jakarta;
|
||||
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.provider.Spi;
|
||||
|
||||
public class JakartaValidatorSpi implements Spi {
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "jakarta-validator";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return JakartaValidatorProvider.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory<?>> getProviderFactoryClass() {
|
||||
return JakartaValidatorProviderFactory.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
// Currently used only by Client Admin API v2
|
||||
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.keycloak.services.client.ClientServiceSpi
|
||||
org.keycloak.models.mapper.ModelMapperSpi
|
||||
org.keycloak.validation.jakarta.JakartaValidatorSpi
|
||||
22
rest/admin-v2/pom.xml
Normal file
22
rest/admin-v2/pom.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-rest-parent</artifactId>
|
||||
<version>999.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>keycloak-admin-v2-parent</artifactId>
|
||||
<name>Keycloak Admin REST API v2 Parent</name>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<modules>
|
||||
<module>rest</module>
|
||||
<module>api</module>
|
||||
<module>providers</module>
|
||||
<module>tests</module>
|
||||
</modules>
|
||||
</project>
|
||||
61
rest/admin-v2/providers/pom.xml
Normal file
61
rest/admin-v2/providers/pom.xml
Normal file
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-admin-v2-parent</artifactId>
|
||||
<version>999.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>keycloak-admin-v2-providers</artifactId>
|
||||
<name>Keycloak Admin API v2 Providers</name>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<maven.compiler.release>17</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-admin-v2-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hibernate.validator</groupId>
|
||||
<artifactId>hibernate-validator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.enterprise</groupId>
|
||||
<artifactId>jakarta.enterprise.cdi-api</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<compilerArgument>
|
||||
-AgeneratedTranslationFilesPath=${project.build.directory}/generated-translation-files
|
||||
</compilerArgument>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct-processor</artifactId>
|
||||
<version>${org.mapstruct.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.RoleModel;
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
import org.mapstruct.Context;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.MappingTarget;
|
||||
import org.mapstruct.Named;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Mapper
|
||||
public interface MapStructClientModelMapper extends ClientModelMapper {
|
||||
@Mapping(target = "displayName", source = "name")
|
||||
@Mapping(target = "appUrl", source = "baseUrl")
|
||||
@Mapping(target = "appRedirectUrls", source = "redirectUris")
|
||||
@Mapping(target = "loginFlows", source = "authenticationFlowBindingOverrides", ignore = true)
|
||||
@Mapping(target = "auth", ignore = true) // TODO
|
||||
@Mapping(target = "roles", source = "rolesStream", qualifiedByName = "getRoleStrings")
|
||||
@Mapping(target = "serviceAccount.enabled", source = "serviceAccountsEnabled")
|
||||
@Mapping(target = "serviceAccount.roles", source = "rolesStream", qualifiedByName = "getServiceAccountRoles")
|
||||
@Override
|
||||
ClientRepresentation fromModel(ClientModel model);
|
||||
|
||||
// we don't want to ignore nulls so that we completely overwrite the state
|
||||
@Override
|
||||
void toModel(@MappingTarget ClientModel model, ClientRepresentation rep, @Context RealmModel realm);
|
||||
|
||||
@Named("getRoleStrings")
|
||||
default Set<String> getRoleStrings(Stream<RoleModel> stream) {
|
||||
return stream.map(RoleModel::getName).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@Named("getServiceAccountRoles")
|
||||
default Set<String> getServiceAccountRoles(Stream<RoleModel> stream) {
|
||||
return stream.filter(f -> true) //TODO check roles for SA
|
||||
.map(RoleModel::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
import org.mapstruct.factory.Mappers;
|
||||
|
||||
public class MapStructModelMapper implements ModelMapper {
|
||||
private final MapStructClientModelMapper clientMapper;
|
||||
|
||||
public MapStructModelMapper() {
|
||||
this.clientMapper = Mappers.getMapper(MapStructClientModelMapper.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientModelMapper clients() {
|
||||
return clientMapper;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.keycloak.models.mapper;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
|
||||
public class MapStructModelMapperFactory implements ModelMapperFactory {
|
||||
public static final String PROVIDER_ID = "default";
|
||||
private static ModelMapper SINGLETON;
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModelMapper create(KeycloakSession session) {
|
||||
if (SINGLETON == null) {
|
||||
SINGLETON = new MapStructModelMapper();
|
||||
}
|
||||
return SINGLETON;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Config.Scope config) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.keycloak.services.client;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.mapper.ClientModelMapper;
|
||||
import org.keycloak.models.mapper.ModelMapper;
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
import org.keycloak.representations.admin.v2.validation.CreateClientDefault;
|
||||
import org.keycloak.services.ServiceException;
|
||||
import org.keycloak.validation.jakarta.JakartaValidatorProvider;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
// TODO
|
||||
public class DefaultClientService implements ClientService {
|
||||
private final KeycloakSession session;
|
||||
private final ClientModelMapper mapper;
|
||||
private final JakartaValidatorProvider validator;
|
||||
|
||||
public DefaultClientService(KeycloakSession session) {
|
||||
this.session = session;
|
||||
this.mapper = session.getProvider(ModelMapper.class).clients();
|
||||
this.validator = session.getProvider(JakartaValidatorProvider.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ClientRepresentation> getClient(RealmModel realm, String clientId,
|
||||
ClientProjectionOptions projectionOptions) {
|
||||
return Optional.ofNullable(realm.getClientByClientId(clientId)).map(mapper::fromModel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientRepresentation> getClients(RealmModel realm, ClientProjectionOptions projectionOptions,
|
||||
ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions) {
|
||||
return realm.getClientsStream().map(mapper::fromModel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CreateOrUpdateResult createOrUpdate(RealmModel realm, ClientRepresentation client, boolean allowUpdate)
|
||||
throws ServiceException {
|
||||
boolean created = false;
|
||||
ClientModel model = realm.getClientByClientId(client.getClientId());
|
||||
if (model != null) {
|
||||
if (!allowUpdate) {
|
||||
throw new ServiceException("Client already exists", Response.Status.CONFLICT);
|
||||
}
|
||||
} else {
|
||||
validator.validate(client, CreateClientDefault.class); // TODO improve it to avoid second validation when we know it is create and not update
|
||||
model = realm.addClient(client.getClientId());
|
||||
created = true;
|
||||
}
|
||||
|
||||
// TODO: defaulting, validation, canonicalization
|
||||
|
||||
mapper.toModel(model, client, realm);
|
||||
|
||||
var updated = mapper.fromModel(model);
|
||||
|
||||
return new CreateOrUpdateResult(updated, created);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientRepresentation deleteClient(RealmModel realm, String clientId) {
|
||||
// TODO Auto-generated method stub
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientRepresentation> deleteClients(RealmModel realm, ClientSearchOptions searchOptions) {
|
||||
// TODO Auto-generated method stub
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.keycloak.services.client;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
|
||||
public class DefaultClientServiceFactory implements ClientServiceFactory {
|
||||
public static final String PROVIDER_ID = "default";
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientService create(KeycloakSession session) {
|
||||
return new DefaultClientService(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Config.Scope config) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.keycloak.services.error;
|
||||
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.ext.ExceptionMapper;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Provider
|
||||
public class ValidationExceptionHandler implements ExceptionMapper<ConstraintViolationException> {
|
||||
|
||||
@Override
|
||||
public Response toResponse(ConstraintViolationException exception) {
|
||||
return Response.status(400)
|
||||
.entity(new ViolationExceptionResponse("Provided data is invalid",
|
||||
exception.getConstraintViolations()
|
||||
.stream()
|
||||
.map(f -> "%s: %s".formatted(f.getPropertyPath(), f.getMessage()))
|
||||
.collect(Collectors.toSet())))
|
||||
.type(MediaType.APPLICATION_JSON_TYPE)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.keycloak.validation.jakarta;
|
||||
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import jakarta.validation.Validator;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class HibernateValidatorProvider implements JakartaValidatorProvider {
|
||||
private final Validator validator;
|
||||
|
||||
public HibernateValidatorProvider(Validator validator) {
|
||||
this.validator = validator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> void validate(T object, Class<?>... groups) throws ConstraintViolationException {
|
||||
var errors = validator.validate(object, groups);
|
||||
if (!errors.isEmpty()) {
|
||||
throw new ConstraintViolationException(errors);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(Function<Validator, Set<ConstraintViolation<?>>> validation) throws ConstraintViolationException {
|
||||
var errors = validation.apply(getValidator());
|
||||
if (!errors.isEmpty()) {
|
||||
throw new ConstraintViolationException(errors);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Validator getValidator() {
|
||||
return validator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.keycloak.validation.jakarta;
|
||||
|
||||
import jakarta.enterprise.inject.spi.CDI;
|
||||
import jakarta.validation.Validator;
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
|
||||
public class HibernateValidatorProviderFactory implements JakartaValidatorProviderFactory {
|
||||
public static final String PROVIDER_ID = "default";
|
||||
private static HibernateValidatorProvider SINGLETON;
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JakartaValidatorProvider create(KeycloakSession session) {
|
||||
if (SINGLETON == null) {
|
||||
SINGLETON = new HibernateValidatorProvider(CDI.current().select(Validator.class).get());
|
||||
}
|
||||
return SINGLETON;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Config.Scope config) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
org.keycloak.models.mapper.MapStructModelMapperFactory
|
||||
@@ -0,0 +1 @@
|
||||
org.keycloak.services.client.DefaultClientServiceFactory
|
||||
@@ -0,0 +1 @@
|
||||
org.keycloak.validation.jakarta.HibernateValidatorProviderFactory
|
||||
48
rest/admin-v2/rest/pom.xml
Normal file
48
rest/admin-v2/rest/pom.xml
Normal file
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-admin-v2-parent</artifactId>
|
||||
<version>999.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>keycloak-admin-v2-rest</artifactId>
|
||||
<name>Keycloak Admin API v2 REST Layer</name>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<maven.compiler.release>17</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-admin-v2-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-server-spi</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.ws.rs</groupId>
|
||||
<artifactId>jakarta.ws.rs-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-services</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.fabric8</groupId>
|
||||
<artifactId>zjsonpatch</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.keycloak.admin.api;
|
||||
|
||||
import jakarta.ws.rs.Path;
|
||||
import org.keycloak.admin.api.realm.RealmsApi;
|
||||
import org.keycloak.provider.Provider;
|
||||
|
||||
public interface AdminApi extends Provider {
|
||||
|
||||
@Path("realms")
|
||||
RealmsApi realms();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.keycloak.admin.api;
|
||||
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
public interface AdminApiFactory extends ProviderFactory<AdminApi> {
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.keycloak.admin.api;
|
||||
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.provider.Spi;
|
||||
|
||||
public class AdminApiSpi implements Spi {
|
||||
public static final String PROVIDER_ID = "admin-api-root";
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return AdminApi.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory<AdminApi>> getProviderFactoryClass() {
|
||||
return AdminApiFactory.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2); // There's currently only Client API for the new Admin API v2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.keycloak.admin.api;
|
||||
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.OPTIONS;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.services.resources.admin.AdminCorsPreflightService;
|
||||
|
||||
@Provider
|
||||
@Path("admin/api")
|
||||
public class AdminRootV2 {
|
||||
|
||||
@Context
|
||||
protected KeycloakSession session;
|
||||
|
||||
@Path("")
|
||||
public AdminApi latestAdminApi() {
|
||||
checkApiEnabled();
|
||||
// we could return the latest Admin API if no version is specified
|
||||
return session.getProvider(AdminApi.class);
|
||||
}
|
||||
|
||||
@Path("v2")
|
||||
public AdminApi adminApi() {
|
||||
checkApiEnabled();
|
||||
return session.getProvider(AdminApi.class);
|
||||
}
|
||||
|
||||
@Path("{any:.*}")
|
||||
@OPTIONS
|
||||
@Operation(hidden = true)
|
||||
public Object preFlight() {
|
||||
checkApiEnabled();
|
||||
return new AdminCorsPreflightService();
|
||||
}
|
||||
|
||||
private void checkApiEnabled() {
|
||||
if (!isAdminApiV2Enabled()) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isAdminApiV2Enabled() {
|
||||
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2); // There's currently only Client API for the new Admin API v2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.keycloak.admin.api;
|
||||
|
||||
import jakarta.ws.rs.NotAuthorizedException;
|
||||
import jakarta.ws.rs.Path;
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.admin.api.realm.DefaultRealmsApi;
|
||||
import org.keycloak.admin.api.realm.RealmsApi;
|
||||
import org.keycloak.models.AdminRoles;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.services.resources.admin.AdminAuth;
|
||||
import org.keycloak.services.resources.admin.AdminRoot;
|
||||
|
||||
public class DefaultAdminApi implements AdminApi {
|
||||
private final KeycloakSession session;
|
||||
private final AdminAuth auth;
|
||||
|
||||
public DefaultAdminApi(KeycloakSession session) {
|
||||
this.session = session;
|
||||
this.auth = AdminRoot.authenticateRealmAdminRequest(session);
|
||||
|
||||
// TODO: refine permissions
|
||||
if (!auth.getRealm().getName().equals(Config.getAdminRealm()) || !auth.hasRealmRole(AdminRoles.ADMIN)) {
|
||||
throw new NotAuthorizedException("Wrong permissions");
|
||||
}
|
||||
}
|
||||
|
||||
@Path("realms")
|
||||
@Override
|
||||
public RealmsApi realms() {
|
||||
return new DefaultRealmsApi(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.keycloak.admin.api;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
|
||||
public class DefaultAdminApiFactory implements AdminApiFactory {
|
||||
public static final String PROVIDER_ID = "default";
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminApi create(KeycloakSession session) {
|
||||
return new DefaultAdminApi(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Config.Scope config) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.keycloak.admin.api;
|
||||
|
||||
public enum FieldValidation {
|
||||
Ignore,
|
||||
Strict,
|
||||
Warn
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.keycloak.admin.api.client;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.PATCH;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
|
||||
import org.keycloak.admin.api.FieldValidation;
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
public interface ClientApi extends Provider {
|
||||
|
||||
// TODO move these
|
||||
String CONTENT_TYPE_MERGE_PATCH = "application/merge-patch+json";
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
ClientRepresentation getClient();
|
||||
|
||||
@PUT
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
ClientRepresentation createOrUpdateClient(@Valid ClientRepresentation client,
|
||||
@QueryParam("fieldValidation") FieldValidation fieldValidation);
|
||||
|
||||
@PATCH
|
||||
@Consumes({MediaType.APPLICATION_JSON_PATCH_JSON, CONTENT_TYPE_MERGE_PATCH})
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
ClientRepresentation patchClient(JsonNode patch, @QueryParam("fieldValidation") FieldValidation fieldValidation);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.keycloak.admin.api.client;
|
||||
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
public interface ClientApiFactory extends ProviderFactory<ClientApi> {
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.keycloak.admin.api.client;
|
||||
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.provider.Spi;
|
||||
|
||||
public class ClientApiSpi implements Spi {
|
||||
public static final String NAME = "admin-api-client";
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return ClientApi.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory<ClientApi>> getProviderFactoryClass() {
|
||||
return ClientApiFactory.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.keycloak.admin.api.client;
|
||||
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
|
||||
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
import org.keycloak.admin.api.FieldValidation;
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import org.keycloak.services.resources.KeycloakOpenAPI;
|
||||
|
||||
@Tag(name = KeycloakOpenAPI.Admin.Tags.CLIENTS_V2)
|
||||
@Extension(name = KeycloakOpenAPI.Profiles.ADMIN, value = "")
|
||||
public interface ClientsApi extends Provider {
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Get all clients", description = "Returns a list of all clients in the realm")
|
||||
Stream<ClientRepresentation> getClients();
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Create a new client", description = "Creates a new client in the realm")
|
||||
ClientRepresentation createClient(@Valid ClientRepresentation client,
|
||||
@QueryParam("fieldValidation") FieldValidation fieldValidation);
|
||||
|
||||
@Path("{id}")
|
||||
ClientApi client(@PathParam("id") String id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.keycloak.admin.api.client;
|
||||
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
public interface ClientsApiFactory extends ProviderFactory<ClientsApi> {
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.keycloak.admin.api.client;
|
||||
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.provider.Spi;
|
||||
|
||||
public class ClientsApiSpi implements Spi {
|
||||
public static final String NAME = "admin-api-clients";
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return ClientsApi.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory<ClientsApi>> getProviderFactoryClass() {
|
||||
return ClientsApiFactory.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package org.keycloak.admin.api.client;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.keycloak.admin.api.FieldValidation;
|
||||
import org.keycloak.http.HttpResponse;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
import org.keycloak.services.ErrorResponse;
|
||||
import org.keycloak.services.ServiceException;
|
||||
import org.keycloak.services.client.ClientService;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectReader;
|
||||
|
||||
import io.fabric8.zjsonpatch.JsonPatch;
|
||||
import io.fabric8.zjsonpatch.JsonPatchException;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
public class DefaultClientApi implements ClientApi {
|
||||
private final KeycloakSession session;
|
||||
private final RealmModel realm;
|
||||
private final ClientModel client;
|
||||
private final ClientService clientService;
|
||||
private HttpResponse response;
|
||||
|
||||
public DefaultClientApi(KeycloakSession session) {
|
||||
this.session = session;
|
||||
this.realm = Objects.requireNonNull(session.getContext().getRealm());
|
||||
this.client = Objects.requireNonNull(session.getContext().getClient());
|
||||
this.clientService = session.getProvider(ClientService.class);
|
||||
this.response = session.getContext().getHttpResponse();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientRepresentation getClient() {
|
||||
return clientService.getClient(realm, client.getClientId(), null)
|
||||
.orElseThrow(() -> new NotFoundException("Cannot find the specified client"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientRepresentation createOrUpdateClient(ClientRepresentation client, FieldValidation fieldValidation) {
|
||||
try {
|
||||
var result = clientService.createOrUpdate(realm, client, true);
|
||||
if (result.created()) {
|
||||
response.setStatus(Response.Status.CREATED.getStatusCode());
|
||||
}
|
||||
return result.representation();
|
||||
} catch (ServiceException e) {
|
||||
throw new WebApplicationException(e.getMessage(), e.getSuggestedResponseStatus().orElse(Response.Status.BAD_REQUEST));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientRepresentation patchClient(JsonNode patch, FieldValidation fieldValidation) {
|
||||
// patches don't yet allow for creating
|
||||
ClientRepresentation client = getClient();
|
||||
try {
|
||||
String contentType = session.getContext().getHttpRequest().getHttpHeaders().getHeaderString(HttpHeaders.CONTENT_TYPE);
|
||||
|
||||
ClientRepresentation updated = null;
|
||||
|
||||
// TODO: there should be a more centralized objectmapper
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
if (MediaType.valueOf(contentType).getSubtype().equals(MediaType.APPLICATION_JSON_PATCH_JSON_TYPE.getSubtype())) {
|
||||
JsonNode patchedNode = JsonPatch.apply(patch, objectMapper.convertValue(client, JsonNode.class));
|
||||
updated = objectMapper.convertValue(patchedNode, ClientRepresentation.class);
|
||||
} else { // must be merge patch
|
||||
final ObjectReader objectReader = objectMapper.readerForUpdating(client);
|
||||
updated = objectReader.readValue(patch);
|
||||
}
|
||||
|
||||
// TODO: reuse in the other methods
|
||||
if (!updated.getAdditionalFields().isEmpty()) {
|
||||
if (fieldValidation == null || fieldValidation == FieldValidation.Strict) {
|
||||
// validation failed
|
||||
throw new WebApplicationException("Payload contains unknown fields: " + updated.getAdditionalFields().keySet(), Response.Status.BAD_REQUEST);
|
||||
} else if (fieldValidation == FieldValidation.Warn) {
|
||||
response.addHeader("WARNING", "Payload contains unknown fields: " + updated.getAdditionalFields().keySet());
|
||||
}
|
||||
}
|
||||
return clientService.createOrUpdate(realm, updated, true).representation();
|
||||
} catch (JsonPatchException e) {
|
||||
// TODO: kubernetes uses 422 instead
|
||||
throw new WebApplicationException(e.getMessage(), Response.Status.BAD_REQUEST);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new WebApplicationException(e.getMessage(), Response.Status.BAD_REQUEST);
|
||||
} catch (IOException e) {
|
||||
throw ErrorResponse.error("Unknown Error Occurred", Response.Status.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.keycloak.admin.api.client;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
|
||||
public class DefaultClientApiFactory implements ClientApiFactory {
|
||||
public static final String PROVIDER_ID = "default";
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientApi create(KeycloakSession session) {
|
||||
return new DefaultClientApi(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Config.Scope config) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.keycloak.admin.api.client;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import org.keycloak.admin.api.FieldValidation;
|
||||
import org.keycloak.http.HttpResponse;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
import org.keycloak.representations.admin.v2.validation.CreateClientDefault;
|
||||
import org.keycloak.services.ServiceException;
|
||||
import org.keycloak.services.client.ClientService;
|
||||
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.keycloak.validation.jakarta.JakartaValidatorProvider;
|
||||
|
||||
public class DefaultClientsApi implements ClientsApi {
|
||||
private final KeycloakSession session;
|
||||
private final RealmModel realm;
|
||||
private final HttpResponse response;
|
||||
private final ClientService clientService;
|
||||
private final JakartaValidatorProvider validator;
|
||||
|
||||
public DefaultClientsApi(KeycloakSession session) {
|
||||
this.session = session;
|
||||
this.realm = Objects.requireNonNull(session.getContext().getRealm());
|
||||
this.clientService = session.getProvider(ClientService.class);
|
||||
this.response = session.getContext().getHttpResponse();
|
||||
this.validator = session.getProvider(JakartaValidatorProvider.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Stream<ClientRepresentation> getClients() {
|
||||
return clientService.getClients(realm, null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientRepresentation createClient(@Valid ClientRepresentation client, FieldValidation fieldValidation) {
|
||||
try {
|
||||
validator.validate(client, CreateClientDefault.class);
|
||||
response.setStatus(Response.Status.CREATED.getStatusCode());
|
||||
return clientService.createOrUpdate(realm, client, false).representation();
|
||||
} catch (ServiceException e) {
|
||||
throw new WebApplicationException(e.getMessage(), e.getSuggestedResponseStatus().orElse(Response.Status.BAD_REQUEST));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientApi client(@PathParam("id") String clientId) {
|
||||
var client = Optional.ofNullable(session.clients().getClientByClientId(realm, clientId)).orElseThrow(() -> new NotFoundException("Client cannot be found"));
|
||||
session.getContext().setClient(client);
|
||||
return session.getProvider(ClientApi.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.keycloak.admin.api.client;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
|
||||
public class DefaultClientsApiFactory implements ClientsApiFactory {
|
||||
public static final String PROVIDER_ID = "default";
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientsApi create(KeycloakSession session) {
|
||||
return new DefaultClientsApi(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Config.Scope config) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.keycloak.admin.api.realm;
|
||||
|
||||
import jakarta.ws.rs.Path;
|
||||
import org.keycloak.admin.api.client.ClientsApi;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class DefaultRealmApi implements RealmApi {
|
||||
private final KeycloakSession session;
|
||||
private final RealmModel realm;
|
||||
|
||||
public DefaultRealmApi(KeycloakSession session) {
|
||||
this.session = session;
|
||||
this.realm = Objects.requireNonNull(session.getContext().getRealm());
|
||||
}
|
||||
|
||||
@Path("clients")
|
||||
@Override
|
||||
public ClientsApi clients() {
|
||||
return session.getProvider(ClientsApi.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.keycloak.admin.api.realm;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
|
||||
public class DefaultRealmApiFactory implements RealmApiFactory {
|
||||
public static final String PROVIDER_ID = "default";
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RealmApi create(KeycloakSession session) {
|
||||
return new DefaultRealmApi(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Config.Scope config) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.keycloak.admin.api.realm;
|
||||
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class DefaultRealmsApi implements RealmsApi {
|
||||
private final KeycloakSession session;
|
||||
|
||||
public DefaultRealmsApi(KeycloakSession session) {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
@Path("{name}")
|
||||
@Override
|
||||
public RealmApi realm(@PathParam("name") String name) {
|
||||
var realm = Optional.ofNullable(session.realms().getRealmByName(name)).orElseThrow(() -> new NotFoundException("Realm cannot be found"));
|
||||
session.getContext().setRealm(realm);
|
||||
return session.getProvider(RealmApi.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.keycloak.admin.api.realm;
|
||||
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
|
||||
public class DefaultRealmsApiFactory implements RealmsApiFactory {
|
||||
public static final String PROVIDER_ID = "default";
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RealmsApi create(KeycloakSession session) {
|
||||
return new DefaultRealmsApi(session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Config.Scope config) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.keycloak.admin.api.realm;
|
||||
|
||||
import jakarta.ws.rs.Path;
|
||||
import org.keycloak.admin.api.client.ClientsApi;
|
||||
import org.keycloak.provider.Provider;
|
||||
|
||||
public interface RealmApi extends Provider {
|
||||
|
||||
@Path("clients")
|
||||
ClientsApi clients();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.keycloak.admin.api.realm;
|
||||
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
public interface RealmApiFactory extends ProviderFactory<RealmApi> {
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.keycloak.admin.api.realm;
|
||||
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.provider.Spi;
|
||||
|
||||
import static org.keycloak.admin.api.AdminRootV2.isAdminApiV2Enabled;
|
||||
|
||||
public class RealmApiSpi implements Spi {
|
||||
public static final String NAME = "admin-api-realm";
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return RealmApi.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory<RealmApi>> getProviderFactoryClass() {
|
||||
return RealmApiFactory.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return isAdminApiV2Enabled();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.keycloak.admin.api.realm;
|
||||
|
||||
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import org.keycloak.provider.Provider;
|
||||
|
||||
public interface RealmsApi extends Provider {
|
||||
|
||||
@Path("{name}")
|
||||
RealmApi realm(@PathParam("name") String name);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.keycloak.admin.api.realm;
|
||||
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
public interface RealmsApiFactory extends ProviderFactory<RealmsApi> {
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.keycloak.admin.api.realm;
|
||||
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.provider.Spi;
|
||||
|
||||
import static org.keycloak.admin.api.AdminRootV2.isAdminApiV2Enabled;
|
||||
|
||||
public class RealmsApiSpi implements Spi {
|
||||
public static final String NAME = "admin-api-realms";
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return RealmsApi.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory<RealmsApi>> getProviderFactoryClass() {
|
||||
return RealmsApiFactory.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return isAdminApiV2Enabled();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
org.keycloak.admin.api.DefaultAdminApiFactory
|
||||
@@ -0,0 +1 @@
|
||||
org.keycloak.admin.api.client.DefaultClientApiFactory
|
||||
@@ -0,0 +1 @@
|
||||
org.keycloak.admin.api.client.DefaultClientsApiFactory
|
||||
@@ -0,0 +1 @@
|
||||
org.keycloak.admin.api.realm.DefaultRealmApiFactory
|
||||
@@ -0,0 +1 @@
|
||||
org.keycloak.admin.api.realm.DefaultRealmsApiFactory
|
||||
@@ -0,0 +1,5 @@
|
||||
org.keycloak.admin.api.AdminApiSpi
|
||||
org.keycloak.admin.api.realm.RealmsApiSpi
|
||||
org.keycloak.admin.api.realm.RealmApiSpi
|
||||
org.keycloak.admin.api.client.ClientsApiSpi
|
||||
org.keycloak.admin.api.client.ClientApiSpi
|
||||
55
rest/admin-v2/tests/pom.xml
Normal file
55
rest/admin-v2/tests/pom.xml
Normal file
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-admin-v2-parent</artifactId>
|
||||
<version>999.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>keycloak-admin-v2-tests</artifactId>
|
||||
<name>Keycloak Admin API v2 Tests</name>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.keycloak.testframework</groupId>
|
||||
<artifactId>keycloak-test-framework-bom</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>import</scope>
|
||||
<type>pom</type>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.keycloak.testframework</groupId>
|
||||
<artifactId>keycloak-test-framework-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak.testframework</groupId>
|
||||
<artifactId>keycloak-test-framework-junit5-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.keycloak</groupId>
|
||||
<artifactId>keycloak-admin-v2-rest</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||
<java.util.concurrent.ForkJoinPool.common.threadFactory>io.quarkus.bootstrap.forkjoin.QuarkusForkJoinWorkerThreadFactory</java.util.concurrent.ForkJoinPool.common.threadFactory>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.keycloak.tests.admin.client.v2;
|
||||
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.keycloak.testframework.annotations.InjectHttpClient;
|
||||
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.keycloak.tests.admin.client.v2.ClientApiV2Test.HOSTNAME_LOCAL_ADMIN;
|
||||
|
||||
/**
|
||||
* @author Vaclav Muzikar <vmuzikar@redhat.com>
|
||||
*/
|
||||
@KeycloakIntegrationTest
|
||||
public class ClientApiV2DisabledTest {
|
||||
@InjectHttpClient
|
||||
CloseableHttpClient client;
|
||||
|
||||
@Test
|
||||
public void getClient() throws Exception {
|
||||
HttpGet request = new HttpGet(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/account");
|
||||
try (var response = client.execute(request)) {
|
||||
assertEquals(404, response.getStatusLine().getStatusCode());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.tests.admin.client.v2;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import org.apache.http.HttpMessage;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.apache.http.client.methods.HttpPatch;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.entity.StringEntity;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.util.EntityUtils;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.keycloak.admin.api.client.ClientApi;
|
||||
import org.keycloak.admin.client.Keycloak;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.representations.admin.v2.ClientRepresentation;
|
||||
import org.keycloak.services.error.ViolationExceptionResponse;
|
||||
import org.keycloak.testframework.annotations.InjectAdminClient;
|
||||
import org.keycloak.testframework.annotations.InjectHttpClient;
|
||||
import org.keycloak.testframework.annotations.InjectRealm;
|
||||
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
|
||||
import org.keycloak.testframework.realm.ManagedRealm;
|
||||
import org.keycloak.testframework.realm.RealmConfig;
|
||||
import org.keycloak.testframework.realm.RealmConfigBuilder;
|
||||
import org.keycloak.testframework.server.KeycloakServerConfig;
|
||||
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.CoreMatchers.is;
|
||||
import static org.hamcrest.Matchers.notNullValue;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
@KeycloakIntegrationTest(config = ClientApiV2Test.AdminV2Config.class)
|
||||
public class ClientApiV2Test {
|
||||
|
||||
public static final String HOSTNAME_LOCAL_ADMIN = "http://localhost:8080/admin/api/v2";
|
||||
private static ObjectMapper mapper;
|
||||
|
||||
@InjectHttpClient
|
||||
CloseableHttpClient client;
|
||||
|
||||
@InjectAdminClient
|
||||
Keycloak adminClient;
|
||||
|
||||
@InjectRealm(config = NoAccessRealmConfig.class)
|
||||
ManagedRealm testRealm;
|
||||
|
||||
@InjectAdminClient(ref = "noAccessClient", client = "myclient", mode = InjectAdminClient.Mode.MANAGED_REALM)
|
||||
Keycloak noAccessAdminClient;
|
||||
|
||||
@BeforeAll
|
||||
public static void setupMapper() {
|
||||
mapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getClient() throws Exception {
|
||||
HttpGet request = new HttpGet(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/account");
|
||||
setAuthHeader(request);
|
||||
try (var response = client.execute(request)) {
|
||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
||||
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
|
||||
assertEquals("account", client.getClientId());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void jsonPatchClient() throws Exception {
|
||||
HttpPatch request = new HttpPatch(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/account");
|
||||
setAuthHeader(request);
|
||||
request.setEntity(new StringEntity("not json"));
|
||||
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_PATCH_JSON);
|
||||
try (var response = client.execute(request)) {
|
||||
EntityUtils.consumeQuietly(response.getEntity());
|
||||
assertEquals(400, response.getStatusLine().getStatusCode());
|
||||
}
|
||||
|
||||
request.setEntity(new StringEntity(
|
||||
"""
|
||||
[{"op": "add", "path": "/description", "value": "I'm a description"}]
|
||||
"""));
|
||||
|
||||
try (var response = client.execute(request)) {
|
||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
||||
|
||||
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
|
||||
assertEquals("I'm a description", client.getDescription());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void jsonMergePatchClient() throws Exception {
|
||||
HttpPatch request = new HttpPatch(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/account");
|
||||
setAuthHeader(request);
|
||||
request.setHeader(HttpHeaders.CONTENT_TYPE, ClientApi.CONTENT_TYPE_MERGE_PATCH);
|
||||
|
||||
ClientRepresentation patch = new ClientRepresentation();
|
||||
patch.setDescription("I'm also a description");
|
||||
|
||||
request.setEntity(new StringEntity(mapper.writeValueAsString(patch)));
|
||||
|
||||
try (var response = client.execute(request)) {
|
||||
assertEquals(200, response.getStatusLine().getStatusCode());
|
||||
|
||||
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
|
||||
assertEquals("I'm also a description", client.getDescription());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clientRepresentationValidation() throws Exception {
|
||||
HttpPost request = new HttpPost(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients");
|
||||
setAuthHeader(request);
|
||||
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
|
||||
|
||||
request.setEntity(new StringEntity("""
|
||||
{
|
||||
"displayName": "something",
|
||||
"appUrl": "notUrl"
|
||||
}
|
||||
"""));
|
||||
|
||||
try (var response = client.execute(request)) {
|
||||
assertThat(response, notNullValue());
|
||||
assertThat(response.getStatusLine().getStatusCode(), is(400));
|
||||
|
||||
var body = mapper.createParser(response.getEntity().getContent()).readValueAs(ViolationExceptionResponse.class);
|
||||
assertThat(body.error(), is("Provided data is invalid"));
|
||||
var violations = body.violations();
|
||||
assertThat(violations.size(), is(1));
|
||||
assertThat(violations.iterator().next(), is("clientId: must not be blank"));
|
||||
}
|
||||
|
||||
request.setEntity(new StringEntity("""
|
||||
{
|
||||
"clientId": "some-client",
|
||||
"displayName": "something",
|
||||
"appUrl": "notUrl",
|
||||
"auth": {
|
||||
"method":"missing-enabled"
|
||||
}
|
||||
}
|
||||
"""));
|
||||
|
||||
try (var response = client.execute(request)) {
|
||||
assertThat(response, notNullValue());
|
||||
assertThat(response.getStatusLine().getStatusCode(), is(400));
|
||||
var body = mapper.createParser(response.getEntity().getContent()).readValueAs(ViolationExceptionResponse.class);
|
||||
assertThat(body.error(), is("Provided data is invalid"));
|
||||
var violations = body.violations();
|
||||
assertThat(violations.size(), is(1));
|
||||
assertThat(violations.iterator().next(), is("appUrl: must be a valid URL"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void authenticationRequired() throws Exception {
|
||||
HttpGet request = new HttpGet(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/account");
|
||||
setAuthHeader(request, noAccessAdminClient);
|
||||
try (var response = client.execute(request)) {
|
||||
assertEquals(401, response.getStatusLine().getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
// TODO Rewrite the tests to not need explicit auth. They should use the admin client directly.
|
||||
private void setAuthHeader(HttpMessage request, Keycloak adminClient) {
|
||||
String token = adminClient.tokenManager().getAccessTokenString();
|
||||
request.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
|
||||
}
|
||||
|
||||
private void setAuthHeader(HttpMessage request) {
|
||||
setAuthHeader(request, this.adminClient);
|
||||
}
|
||||
|
||||
public static class AdminV2Config implements KeycloakServerConfig {
|
||||
@Override
|
||||
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
|
||||
return config.features(Profile.Feature.CLIENT_ADMIN_API_V2);
|
||||
}
|
||||
}
|
||||
|
||||
public static class NoAccessRealmConfig implements RealmConfig {
|
||||
|
||||
@Override
|
||||
public RealmConfigBuilder configure(RealmConfigBuilder realm) {
|
||||
realm.addClient("myclient")
|
||||
.secret("mysecret")
|
||||
.serviceAccountsEnabled(true);
|
||||
return realm;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
kc.test.log.level=WARN
|
||||
|
||||
kc.test.log.filter=true
|
||||
|
||||
kc.test.log.category."org.keycloak.tests".level=INFO
|
||||
|
||||
kc.test.log.category."testinfo".level=INFO
|
||||
kc.test.log.category."org.keycloak.it".level=INFO
|
||||
kc.test.log.category."org.keycloak".level=WARN
|
||||
kc.test.log.category."managed.keycloak".level=WARN
|
||||
@@ -32,6 +32,7 @@
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<modules>
|
||||
<module>admin-v2</module>
|
||||
<module>admin-ui-ext</module>
|
||||
</modules>
|
||||
|
||||
|
||||
@@ -81,7 +81,11 @@
|
||||
<groupId>org.twitter4j</groupId>
|
||||
<artifactId>twitter4j-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.hibernate.validator</groupId>
|
||||
<artifactId>hibernate-validator</artifactId>
|
||||
<version>${hibernate-validator.version}</version> <!--Not sure why we need to set it as it should be part of dependencyManagement-->
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jboss.logging</groupId>
|
||||
<artifactId>jboss-logging</artifactId>
|
||||
|
||||
@@ -4,6 +4,7 @@ import static org.keycloak.services.resources.KeycloakApplication.getSessionFact
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParseException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import jakarta.validation.ValidationException;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.OAuthErrorException;
|
||||
@@ -100,6 +101,8 @@ public class KeycloakErrorHandler implements ExceptionMapper<Throwable> {
|
||||
error.setErrorDescription("Cannot parse the JSON");
|
||||
} else if (isServerError) {
|
||||
error.setErrorDescription("For more on this error consult the server log.");
|
||||
} else if (throwable instanceof ValidationException) {
|
||||
error.setErrorDescription(throwable.getMessage());
|
||||
}
|
||||
|
||||
return Response.status(responseStatus)
|
||||
|
||||
@@ -38,6 +38,7 @@ public class KeycloakOpenAPI {
|
||||
public static final String ATTACK_DETECTION = "Attack Detection";
|
||||
public static final String AUTHENTICATION_MANAGEMENT = "Authentication Management";
|
||||
public static final String CLIENTS = "Clients";
|
||||
public static final String CLIENTS_V2 = "Clients (v2)";
|
||||
public static final String CLIENT_ATTRIBUTE_CERTIFICATE = "Client Attribute Certificate";
|
||||
public static final String CLIENT_INITIAL_ACCESS = "Client Initial Access";
|
||||
public static final String CLIENT_REGISTRATION_POLICY = "Client Registration Policy";
|
||||
|
||||
@@ -178,7 +178,9 @@ public class AdminRoot {
|
||||
}
|
||||
|
||||
|
||||
protected AdminAuth authenticateRealmAdminRequest(HttpHeaders headers) {
|
||||
public static AdminAuth authenticateRealmAdminRequest(KeycloakSession session) {
|
||||
HttpHeaders headers = session.getContext().getRequestHeaders();
|
||||
|
||||
String tokenString = AppAuthManager.extractAuthorizationHeaderToken(headers);
|
||||
if (tokenString == null) throw new NotAuthorizedException("Bearer");
|
||||
AccessToken token;
|
||||
@@ -238,7 +240,7 @@ public class AdminRoot {
|
||||
return new RealmsAdminResourcePreflight(session, null, tokenManager, request);
|
||||
}
|
||||
|
||||
AdminAuth auth = authenticateRealmAdminRequest(session.getContext().getRequestHeaders());
|
||||
AdminAuth auth = authenticateRealmAdminRequest(session);
|
||||
if (auth != null) {
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debugf("authenticated admin access for: %s", auth.getUser().getUsername());
|
||||
@@ -280,7 +282,7 @@ public class AdminRoot {
|
||||
return new AdminCorsPreflightService();
|
||||
}
|
||||
|
||||
AdminAuth auth = authenticateRealmAdminRequest(session.getContext().getRequestHeaders());
|
||||
AdminAuth auth = authenticateRealmAdminRequest(session);
|
||||
if (!AdminPermissions.realms(session, auth).isAdmin()) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user