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:
Václav Muzikář
2025-11-03 14:31:54 +01:00
committed by GitHub
parent f7735b573c
commit 9c86eae7ed
102 changed files with 2641 additions and 12 deletions

View File

@@ -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

View File

@@ -59,3 +59,5 @@ js/libs/ui-shared/ ci ci-webauthn
*.tsx codeql-typescript
testsuite::database-suite ci-store
rest/admin-v2/ admin-v2

View File

@@ -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 \

View File

@@ -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

View File

@@ -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
View File

@@ -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>

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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() {

View File

@@ -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);
}
}

View File

@@ -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());
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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>

View 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");
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View 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>

View File

@@ -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);
}

View File

@@ -0,0 +1,11 @@
package org.keycloak.models.mapper;
import org.keycloak.provider.Provider;
public interface ModelMapper extends Provider {
ClientModelMapper clients();
default void close() {
}
}

View File

@@ -0,0 +1,6 @@
package org.keycloak.models.mapper;
import org.keycloak.provider.ProviderFactory;
public interface ModelMapperFactory extends ProviderFactory<ModelMapper> {
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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 {
}

View File

@@ -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 {
}

View File

@@ -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 {
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,6 @@
package org.keycloak.services.client;
import org.keycloak.provider.ProviderFactory;
public interface ClientServiceFactory extends ProviderFactory<ClientService> {
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,6 @@
package org.keycloak.services.error;
import java.util.Set;
public record ViolationExceptionResponse(String error, Set<String> violations) {
}

View File

@@ -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();
}

View File

@@ -0,0 +1,6 @@
package org.keycloak.validation.jakarta;
import org.keycloak.provider.ProviderFactory;
public interface JakartaValidatorProviderFactory extends ProviderFactory<JakartaValidatorProvider> {
}

View File

@@ -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);
}
}

View File

@@ -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
View 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>

View 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>

View File

@@ -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());
}
}

View File

@@ -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;
}
}

View File

@@ -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() {
}
}

View File

@@ -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() {
}
}

View File

@@ -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() {
}
}

View File

@@ -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();
}
}

View File

@@ -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() {
}
}

View File

@@ -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() {
}
}

View File

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

View File

@@ -0,0 +1 @@
org.keycloak.services.client.DefaultClientServiceFactory

View File

@@ -0,0 +1 @@
org.keycloak.validation.jakarta.HibernateValidatorProviderFactory

View 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>

View File

@@ -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();
}

View File

@@ -0,0 +1,6 @@
package org.keycloak.admin.api;
import org.keycloak.provider.ProviderFactory;
public interface AdminApiFactory extends ProviderFactory<AdminApi> {
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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() {
}
}

View File

@@ -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() {
}
}

View File

@@ -0,0 +1,7 @@
package org.keycloak.admin.api;
public enum FieldValidation {
Ignore,
Strict,
Warn
}

View File

@@ -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);
}

View File

@@ -0,0 +1,6 @@
package org.keycloak.admin.api.client;
import org.keycloak.provider.ProviderFactory;
public interface ClientApiFactory extends ProviderFactory<ClientApi> {
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,6 @@
package org.keycloak.admin.api.client;
import org.keycloak.provider.ProviderFactory;
public interface ClientsApiFactory extends ProviderFactory<ClientsApi> {
}

View File

@@ -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);
}
}

View File

@@ -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() {
}
}

View File

@@ -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() {
}
}

View File

@@ -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() {
}
}

View File

@@ -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() {
}
}

View File

@@ -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() {}
}

View File

@@ -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() {
}
}

View File

@@ -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() {
}
}

View File

@@ -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() {
}
}

View File

@@ -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();
}

View File

@@ -0,0 +1,6 @@
package org.keycloak.admin.api.realm;
import org.keycloak.provider.ProviderFactory;
public interface RealmApiFactory extends ProviderFactory<RealmApi> {
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,6 @@
package org.keycloak.admin.api.realm;
import org.keycloak.provider.ProviderFactory;
public interface RealmsApiFactory extends ProviderFactory<RealmsApi> {
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1 @@
org.keycloak.admin.api.DefaultAdminApiFactory

View File

@@ -0,0 +1 @@
org.keycloak.admin.api.client.DefaultClientApiFactory

View File

@@ -0,0 +1 @@
org.keycloak.admin.api.client.DefaultClientsApiFactory

View File

@@ -0,0 +1 @@
org.keycloak.admin.api.realm.DefaultRealmApiFactory

View File

@@ -0,0 +1 @@
org.keycloak.admin.api.realm.DefaultRealmsApiFactory

View File

@@ -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

View 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>

View File

@@ -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());
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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

View File

@@ -32,6 +32,7 @@
<packaging>pom</packaging>
<modules>
<module>admin-v2</module>
<module>admin-ui-ext</module>
</modules>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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";

View File

@@ -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