diff --git a/.github/actions/conditional/action.yml b/.github/actions/conditional/action.yml
index 98eb6058b41..5c0076fe3c4 100644
--- a/.github/actions/conditional/action.yml
+++ b/.github/actions/conditional/action.yml
@@ -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
diff --git a/.github/actions/conditional/conditions b/.github/actions/conditional/conditions
index 4f42bc8e581..0ad43dd781e 100644
--- a/.github/actions/conditional/conditions
+++ b/.github/actions/conditional/conditions
@@ -59,3 +59,5 @@ js/libs/ui-shared/ ci ci-webauthn
*.tsx codeql-typescript
testsuite::database-suite ci-store
+
+rest/admin-v2/ admin-v2
diff --git a/.github/scripts/find-modules-with-unit-tests.sh b/.github/scripts/find-modules-with-unit-tests.sh
index d62896f2c6b..b6900852ccb 100755
--- a/.github/scripts/find-modules-with-unit-tests.sh
+++ b/.github/scripts/find-modules-with-unit-tests.sh
@@ -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 \
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 17f54e78ceb..aabb22948b3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java
index 608a0180511..7bc62889d58 100755
--- a/common/src/main/java/org/keycloak/common/Profile.java
+++ b/common/src/main/java/org/keycloak/common/Profile.java
@@ -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 Deprecate for removal the Instagram social broker.
*/
diff --git a/pom.xml b/pom.xml
index b3579c32d98..d3cabd03cb9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -92,6 +92,7 @@
6.2.13.Final
6.2.13.Final
15.0.19.Final
+ 9.0.1.Final
5.0.14.Final
${protostream.version}
@@ -228,6 +229,8 @@
1.0.0.Alpha8
+ 1.6.3
+
true
true
@@ -399,6 +402,11 @@
xsom
${org.glassfish.jaxb.xsom.version}
+
+ org.mapstruct
+ mapstruct
+ ${org.mapstruct.version}
+
org.bouncycastle
@@ -591,6 +599,11 @@
h2
${h2.version}
+
+ org.hibernate.validator
+ hibernate-validator
+ ${hibernate-validator.version}
+
org.hibernate.orm
hibernate-c3p0
@@ -1127,6 +1140,21 @@
keycloak-rest-admin-ui-ext
${project.version}
+
+ org.keycloak
+ keycloak-admin-v2-api
+ ${project.version}
+
+
+ org.keycloak
+ keycloak-admin-v2-providers
+ ${project.version}
+
+
+ org.keycloak
+ keycloak-admin-v2-rest
+ ${project.version}
+
org.keycloak
keycloak-saml-wildfly-modules
diff --git a/quarkus/config-api/src/main/java/org/keycloak/config/OpenApiOptions.java b/quarkus/config-api/src/main/java/org/keycloak/config/OpenApiOptions.java
new file mode 100644
index 00000000000..c04e416bd80
--- /dev/null
+++ b/quarkus/config-api/src/main/java/org/keycloak/config/OpenApiOptions.java
@@ -0,0 +1,17 @@
+package org.keycloak.config;
+
+public class OpenApiOptions {
+
+ public static final Option 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 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();
+}
diff --git a/quarkus/config-api/src/main/java/org/keycloak/config/OptionCategory.java b/quarkus/config-api/src/main/java/org/keycloak/config/OptionCategory.java
index 67b91a914a5..f85b11b9006 100644
--- a/quarkus/config-api/src/main/java/org/keycloak/config/OptionCategory.java
+++ b/quarkus/config-api/src/main/java/org/keycloak/config/OptionCategory.java
@@ -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);
diff --git a/quarkus/deployment/pom.xml b/quarkus/deployment/pom.xml
index a1a3c647f53..f7cf59199f6 100644
--- a/quarkus/deployment/pom.xml
+++ b/quarkus/deployment/pom.xml
@@ -138,6 +138,10 @@
io.quarkus
quarkus-rest-jackson-deployment
+
+ io.quarkus
+ quarkus-hibernate-validator-deployment
+
io.quarkus
quarkus-hibernate-orm-deployment
@@ -236,6 +240,14 @@
io.quarkus
quarkus-smallrye-health-deployment
+
+ io.quarkus
+ quarkus-smallrye-openapi-deployment
+
+
+ io.quarkus
+ quarkus-swagger-ui-deployment
+
io.quarkus
quarkus-micrometer-deployment
diff --git a/quarkus/runtime/pom.xml b/quarkus/runtime/pom.xml
index 73d83c7c66c..0dbe29b6a04 100644
--- a/quarkus/runtime/pom.xml
+++ b/quarkus/runtime/pom.xml
@@ -135,12 +135,31 @@
rdf-urdna
+
+ org.mapstruct
+ mapstruct
+ ${org.mapstruct.version}
+
+
+
+
+ io.quarkus
+ quarkus-hibernate-validator
+
+
io.smallrye.config
smallrye-config-source-keystore
-
+
+ io.quarkus
+ quarkus-smallrye-openapi
+
+
+ io.quarkus
+ quarkus-swagger-ui
+
info.picocli
@@ -394,7 +413,44 @@
+
+ org.keycloak
+ keycloak-admin-v2-api
+
+
+ *
+ *
+
+
+
+
+
+ org.keycloak
+ keycloak-admin-v2-providers
+
+
+ *
+ *
+
+
+
+
+
+ org.keycloak
+ keycloak-admin-v2-rest
+
+
+ *
+ *
+
+
+
+
+
+ io.fabric8
+ zjsonpatch
+
org.jboss.logging
commons-logging-jboss-logging
diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ManagementPropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ManagementPropertyMappers.java
index b521fcf20cc..89e1129d85e 100644
--- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ManagementPropertyMappers.java
+++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/ManagementPropertyMappers.java
@@ -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() {
diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/OpenApiPropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/OpenApiPropertyMappers.java
new file mode 100644
index 00000000000..272e3dc9dd8
--- /dev/null
+++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/OpenApiPropertyMappers.java
@@ -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);
+ }
+}
diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java
index 4643d3f7b51..d6bd80bf9d3 100644
--- a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java
+++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/PropertyMappers.java
@@ -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());
}
diff --git a/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/oas/OASModelFilter.java b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/oas/OASModelFilter.java
new file mode 100644
index 00000000000..04aadb10e98
--- /dev/null
+++ b/quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/oas/OASModelFilter.java
@@ -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 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 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 operationsOf(PathItem pi) {
+ List ops = new ArrayList<>(8);
+ if (pi.getGET() != null) ops.add(pi.getGET());
+ if (pi.getPOST() != null) ops.add(pi.getPOST());
+ if (pi.getPUT() != null) ops.add(pi.getPUT());
+ if (pi.getPATCH() != null) ops.add(pi.getPATCH());
+ if (pi.getDELETE() != null) ops.add(pi.getDELETE());
+ if (pi.getHEAD() != null) ops.add(pi.getHEAD());
+ if (pi.getOPTIONS() != null) ops.add(pi.getOPTIONS());
+ if (pi.getTRACE() != null) ops.add(pi.getTRACE());
+ return ops;
+ }
+}
diff --git a/quarkus/runtime/src/main/resources/application.properties b/quarkus/runtime/src/main/resources/application.properties
index 354b1100ce0..a7b9fe6b557 100644
--- a/quarkus/runtime/src/main/resources/application.properties
+++ b/quarkus/runtime/src/main/resources/application.properties
@@ -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
\ No newline at end of file
diff --git a/quarkus/server/pom.xml b/quarkus/server/pom.xml
index 2bb794a7334..9efabb63275 100644
--- a/quarkus/server/pom.xml
+++ b/quarkus/server/pom.xml
@@ -40,6 +40,10 @@
importmap
provided
+
+ io.quarkus
+ quarkus-smallrye-openapi
+
@@ -70,6 +74,7 @@
${kc.home.dir}
dev-file
io.quarkus.bootstrap.forkjoin.QuarkusForkJoinWorkerThreadFactory
+ ${project.build.directory}
diff --git a/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/OpenApiDistTest.java b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/OpenApiDistTest.java
new file mode 100644
index 00000000000..f17635b82a1
--- /dev/null
+++ b/quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/OpenApiDistTest.java
@@ -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");
+ }
+}
diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelpAll.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelpAll.approved.txt
index 609e603eaaf..ff431c336f7 100644
--- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelpAll.approved.txt
+++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testExportHelpAll.approved.txt
@@ -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
+ 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
+ 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
diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelpAll.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelpAll.approved.txt
index 12f1e684852..4ea34c06354 100644
--- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelpAll.approved.txt
+++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testImportHelpAll.approved.txt
@@ -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
+ 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
+ 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
diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartDevHelpAll.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartDevHelpAll.approved.txt
index ddd908b59bb..1a07c53ad41 100644
--- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartDevHelpAll.approved.txt
+++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartDevHelpAll.approved.txt
@@ -708,6 +708,17 @@ Security:
feature is enabled. Possible values are: non-strict, strict. Default:
disabled.
+OpenAPI configuration:
+
+--openapi-enabled
+ 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
+ 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
diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartHelpAll.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartHelpAll.approved.txt
index a64f4bc6bee..72ad2cb173f 100644
--- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartHelpAll.approved.txt
+++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testStartHelpAll.approved.txt
@@ -709,6 +709,17 @@ Security:
feature is enabled. Possible values are: non-strict, strict. Default:
disabled.
+OpenAPI configuration:
+
+--openapi-enabled
+ 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
+ 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
diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityCheckHelpAll.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityCheckHelpAll.approved.txt
index 037d9b01de2..452bd09de8b 100644
--- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityCheckHelpAll.approved.txt
+++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityCheckHelpAll.approved.txt
@@ -708,6 +708,17 @@ Security:
feature is enabled. Possible values are: non-strict, strict. Default:
disabled.
+OpenAPI configuration:
+
+--openapi-enabled
+ 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
+ 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
diff --git a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityMetadataHelpAll.approved.txt b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityMetadataHelpAll.approved.txt
index 21eebd6f8c5..29c99fdd5f8 100644
--- a/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityMetadataHelpAll.approved.txt
+++ b/quarkus/tests/integration/src/test/resources/org/keycloak/it/cli/dist/approvals/cli/help/HelpCommandDistTest.testUpdateCompatibilityMetadataHelpAll.approved.txt
@@ -706,6 +706,17 @@ Security:
feature is enabled. Possible values are: non-strict, strict. Default:
disabled.
+OpenAPI configuration:
+
+--openapi-enabled
+ 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
+ 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
diff --git a/rest/admin-v2/api/pom.xml b/rest/admin-v2/api/pom.xml
new file mode 100644
index 00000000000..11d876a215d
--- /dev/null
+++ b/rest/admin-v2/api/pom.xml
@@ -0,0 +1,44 @@
+
+
+ 4.0.0
+
+ org.keycloak
+ keycloak-admin-v2-parent
+ 999.0.0-SNAPSHOT
+
+
+ keycloak-admin-v2-api
+ Keycloak Admin API v2 Interfaces
+
+
+ 17
+ 17
+ 17
+ UTF-8
+
+
+
+
+ org.keycloak
+ keycloak-server-spi
+
+
+ org.keycloak
+ keycloak-core
+
+
+ org.hibernate.validator
+ hibernate-validator
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+
+
+ jakarta.ws.rs
+ jakarta.ws.rs-api
+
+
+
\ No newline at end of file
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapper.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapper.java
new file mode 100644
index 00000000000..7ebb422079f
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapper.java
@@ -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);
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapper.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapper.java
new file mode 100644
index 00000000000..1c4f5d1019e
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapper.java
@@ -0,0 +1,11 @@
+package org.keycloak.models.mapper;
+
+import org.keycloak.provider.Provider;
+
+public interface ModelMapper extends Provider {
+
+ ClientModelMapper clients();
+
+ default void close() {
+ }
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapperFactory.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapperFactory.java
new file mode 100644
index 00000000000..2393d4a9eee
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapperFactory.java
@@ -0,0 +1,6 @@
+package org.keycloak.models.mapper;
+
+import org.keycloak.provider.ProviderFactory;
+
+public interface ModelMapperFactory extends ProviderFactory {
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapperSpi.java b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapperSpi.java
new file mode 100644
index 00000000000..41fedc931de
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapperSpi.java
@@ -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> 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);
+ }
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseRepresentation.java b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseRepresentation.java
new file mode 100644
index 00000000000..83415699db0
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseRepresentation.java
@@ -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 additionalFields = new LinkedHashMap();
+
+ @JsonAnyGetter
+ public Map getAdditionalFields() {
+ return additionalFields;
+ }
+
+ @JsonAnySetter
+ public void setAdditionalField(String name, Object value) {
+ this.additionalFields.put(name, value);
+ }
+
+ public void setAdditionalFields(Map additionalFields) {
+ this.additionalFields = additionalFields;
+ }
+
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/ClientRepresentation.java b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/ClientRepresentation.java
new file mode 100644
index 00000000000..f2ee3cd63ab
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/ClientRepresentation.java
@@ -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();
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ @JsonPropertyDescription("Login flows that are enabled for this client")
+ private Set<@NotBlank String> loginFlows = new LinkedHashSet();
+
+ @Valid
+ @JsonPropertyDescription("Authentication configuration for this client")
+ private Auth auth;
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ @JsonPropertyDescription("Web origins that are allowed to make requests to this client")
+ private Set<@NotBlank String> webOrigins = new LinkedHashSet();
+
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ @JsonPropertyDescription("Roles associated with this client")
+ private Set<@NotBlank String> roles = new LinkedHashSet();
+
+ @Valid
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ @JsonPropertyDescription("Service account configuration for this client")
+ private ServiceAccount serviceAccount;
+
+ public ClientRepresentation() {}
+
+ public ClientRepresentation(String clientId) {
+ this.clientId = clientId;
+ }
+
+ public String getClientId() {
+ return clientId;
+ }
+
+ public void setClientId(String clientId) {
+ this.clientId = clientId;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ public void setDisplayName(String displayName) {
+ this.displayName = displayName;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public String getProtocol() {
+ return protocol;
+ }
+
+ public void setProtocol(String protocol) {
+ this.protocol = protocol;
+ }
+
+ public Boolean getEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(Boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public String getAppUrl() {
+ return appUrl;
+ }
+
+ public void setAppUrl(String appUrl) {
+ this.appUrl = appUrl;
+ }
+
+ public Set getAppRedirectUrls() {
+ return appRedirectUrls;
+ }
+
+ public void setAppRedirectUrls(Set appRedirectUrls) {
+ this.appRedirectUrls = appRedirectUrls;
+ }
+
+ public Set getLoginFlows() {
+ return loginFlows;
+ }
+
+ public void setLoginFlows(Set loginFlows) {
+ this.loginFlows = loginFlows;
+ }
+
+ public Auth getAuth() {
+ return auth;
+ }
+
+ public void setAuth(Auth auth) {
+ this.auth = auth;
+ }
+
+ public Set getWebOrigins() {
+ return webOrigins;
+ }
+
+ public void setWebOrigins(Set webOrigins) {
+ this.webOrigins = webOrigins;
+ }
+
+ public Set getRoles() {
+ return roles;
+ }
+
+ public void setRoles(Set roles) {
+ this.roles = roles;
+ }
+
+ public ServiceAccount getServiceAccount() {
+ return serviceAccount;
+ }
+
+ public void setServiceAccount(ServiceAccount serviceAccount) {
+ this.serviceAccount = serviceAccount;
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_ABSENT)
+ public static class Auth {
+
+ @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 roles = new LinkedHashSet();
+
+ public Boolean getEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(Boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public Set getRoles() {
+ return roles;
+ }
+
+ public void setRoles(Set roles) {
+ this.roles = roles;
+ }
+ }
+}
+
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/validation/CreateClient.java b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/validation/CreateClient.java
new file mode 100644
index 00000000000..28f9333de6f
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/validation/CreateClient.java
@@ -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 {
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/validation/CreateClientDefault.java b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/validation/CreateClientDefault.java
new file mode 100644
index 00000000000..bf4a4ff18de
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/validation/CreateClientDefault.java
@@ -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 {
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/services/Service.java b/rest/admin-v2/api/src/main/java/org/keycloak/services/Service.java
new file mode 100644
index 00000000000..7e46c4a0c8c
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/services/Service.java
@@ -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 {
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/services/ServiceException.java b/rest/admin-v2/api/src/main/java/org/keycloak/services/ServiceException.java
new file mode 100644
index 00000000000..19f2efe9769
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/services/ServiceException.java
@@ -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 getSuggestedResponseStatus() {
+ return Optional.ofNullable(suggestedHttpResponseStatus);
+ }
+
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientService.java b/rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientService.java
new file mode 100644
index 00000000000..df90ee0301a
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientService.java
@@ -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 getClient(RealmModel realm, String clientId, ClientProjectionOptions projectionOptions);
+
+ Stream getClients(RealmModel realm, ClientProjectionOptions projectionOptions, ClientSearchOptions searchOptions, ClientSortAndSliceOptions sortAndSliceOptions);
+
+ ClientRepresentation deleteClient(RealmModel realm, String clientId);
+
+ Stream deleteClients(RealmModel realm, ClientSearchOptions searchOptions);
+
+ CreateOrUpdateResult createOrUpdate(RealmModel realm, ClientRepresentation client, boolean allowUpdate) throws ServiceException;
+
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientServiceFactory.java b/rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientServiceFactory.java
new file mode 100644
index 00000000000..7d247fc41a9
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientServiceFactory.java
@@ -0,0 +1,6 @@
+package org.keycloak.services.client;
+
+import org.keycloak.provider.ProviderFactory;
+
+public interface ClientServiceFactory extends ProviderFactory {
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientServiceSpi.java b/rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientServiceSpi.java
new file mode 100644
index 00000000000..4fcbaabfcd3
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientServiceSpi.java
@@ -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> getProviderFactoryClass() {
+ return ClientServiceFactory.class;
+ }
+
+ @Override
+ public boolean isInternal() {
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2);
+ }
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/services/error/ViolationExceptionResponse.java b/rest/admin-v2/api/src/main/java/org/keycloak/services/error/ViolationExceptionResponse.java
new file mode 100644
index 00000000000..d229f566613
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/services/error/ViolationExceptionResponse.java
@@ -0,0 +1,6 @@
+package org.keycloak.services.error;
+
+import java.util.Set;
+
+public record ViolationExceptionResponse(String error, Set violations) {
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/validation/jakarta/JakartaValidatorProvider.java b/rest/admin-v2/api/src/main/java/org/keycloak/validation/jakarta/JakartaValidatorProvider.java
new file mode 100644
index 00000000000..2a7a4b3f032
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/validation/jakarta/JakartaValidatorProvider.java
@@ -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 {
+
+ void validate(T object, Class>... groups) throws ConstraintViolationException;
+
+ void validate(Function>> validation) throws ConstraintViolationException;
+
+ Validator getValidator();
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/validation/jakarta/JakartaValidatorProviderFactory.java b/rest/admin-v2/api/src/main/java/org/keycloak/validation/jakarta/JakartaValidatorProviderFactory.java
new file mode 100644
index 00000000000..3c2365ea9dd
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/validation/jakarta/JakartaValidatorProviderFactory.java
@@ -0,0 +1,6 @@
+package org.keycloak.validation.jakarta;
+
+import org.keycloak.provider.ProviderFactory;
+
+public interface JakartaValidatorProviderFactory extends ProviderFactory {
+}
diff --git a/rest/admin-v2/api/src/main/java/org/keycloak/validation/jakarta/JakartaValidatorSpi.java b/rest/admin-v2/api/src/main/java/org/keycloak/validation/jakarta/JakartaValidatorSpi.java
new file mode 100644
index 00000000000..ebf314b99dc
--- /dev/null
+++ b/rest/admin-v2/api/src/main/java/org/keycloak/validation/jakarta/JakartaValidatorSpi.java
@@ -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);
+ }
+}
diff --git a/rest/admin-v2/api/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/rest/admin-v2/api/src/main/resources/META-INF/services/org.keycloak.provider.Spi
new file mode 100644
index 00000000000..ed56a9f2837
--- /dev/null
+++ b/rest/admin-v2/api/src/main/resources/META-INF/services/org.keycloak.provider.Spi
@@ -0,0 +1,3 @@
+org.keycloak.services.client.ClientServiceSpi
+org.keycloak.models.mapper.ModelMapperSpi
+org.keycloak.validation.jakarta.JakartaValidatorSpi
\ No newline at end of file
diff --git a/rest/admin-v2/pom.xml b/rest/admin-v2/pom.xml
new file mode 100644
index 00000000000..e29537fb897
--- /dev/null
+++ b/rest/admin-v2/pom.xml
@@ -0,0 +1,22 @@
+
+
+ 4.0.0
+
+ org.keycloak
+ keycloak-rest-parent
+ 999.0.0-SNAPSHOT
+
+
+ keycloak-admin-v2-parent
+ Keycloak Admin REST API v2 Parent
+ pom
+
+
+ rest
+ api
+ providers
+ tests
+
+
\ No newline at end of file
diff --git a/rest/admin-v2/providers/pom.xml b/rest/admin-v2/providers/pom.xml
new file mode 100644
index 00000000000..386af0ce4a6
--- /dev/null
+++ b/rest/admin-v2/providers/pom.xml
@@ -0,0 +1,61 @@
+
+
+ 4.0.0
+
+ org.keycloak
+ keycloak-admin-v2-parent
+ 999.0.0-SNAPSHOT
+
+
+ keycloak-admin-v2-providers
+ Keycloak Admin API v2 Providers
+
+
+ 17
+ 17
+ 17
+ UTF-8
+
+
+
+
+ org.keycloak
+ keycloak-admin-v2-api
+
+
+ org.mapstruct
+ mapstruct
+
+
+ org.hibernate.validator
+ hibernate-validator
+
+
+ jakarta.enterprise
+ jakarta.enterprise.cdi-api
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ -AgeneratedTranslationFilesPath=${project.build.directory}/generated-translation-files
+
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${org.mapstruct.version}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/rest/admin-v2/providers/src/main/java/org/keycloak/models/mapper/MapStructClientModelMapper.java b/rest/admin-v2/providers/src/main/java/org/keycloak/models/mapper/MapStructClientModelMapper.java
new file mode 100644
index 00000000000..dfe2b478e44
--- /dev/null
+++ b/rest/admin-v2/providers/src/main/java/org/keycloak/models/mapper/MapStructClientModelMapper.java
@@ -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 getRoleStrings(Stream stream) {
+ return stream.map(RoleModel::getName).collect(Collectors.toSet());
+ }
+
+ @Named("getServiceAccountRoles")
+ default Set getServiceAccountRoles(Stream stream) {
+ return stream.filter(f -> true) //TODO check roles for SA
+ .map(RoleModel::getName)
+ .collect(Collectors.toSet());
+ }
+}
diff --git a/rest/admin-v2/providers/src/main/java/org/keycloak/models/mapper/MapStructModelMapper.java b/rest/admin-v2/providers/src/main/java/org/keycloak/models/mapper/MapStructModelMapper.java
new file mode 100644
index 00000000000..ea384f98563
--- /dev/null
+++ b/rest/admin-v2/providers/src/main/java/org/keycloak/models/mapper/MapStructModelMapper.java
@@ -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;
+ }
+}
diff --git a/rest/admin-v2/providers/src/main/java/org/keycloak/models/mapper/MapStructModelMapperFactory.java b/rest/admin-v2/providers/src/main/java/org/keycloak/models/mapper/MapStructModelMapperFactory.java
new file mode 100644
index 00000000000..9c6468d21fa
--- /dev/null
+++ b/rest/admin-v2/providers/src/main/java/org/keycloak/models/mapper/MapStructModelMapperFactory.java
@@ -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() {
+
+ }
+}
diff --git a/rest/admin-v2/providers/src/main/java/org/keycloak/services/client/DefaultClientService.java b/rest/admin-v2/providers/src/main/java/org/keycloak/services/client/DefaultClientService.java
new file mode 100644
index 00000000000..ac9d6001e21
--- /dev/null
+++ b/rest/admin-v2/providers/src/main/java/org/keycloak/services/client/DefaultClientService.java
@@ -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 getClient(RealmModel realm, String clientId,
+ ClientProjectionOptions projectionOptions) {
+ return Optional.ofNullable(realm.getClientByClientId(clientId)).map(mapper::fromModel);
+ }
+
+ @Override
+ public Stream 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 deleteClients(RealmModel realm, ClientSearchOptions searchOptions) {
+ // TODO Auto-generated method stub
+ return null;
+ }
+
+
+ @Override
+ public void close() {
+
+ }
+
+}
diff --git a/rest/admin-v2/providers/src/main/java/org/keycloak/services/client/DefaultClientServiceFactory.java b/rest/admin-v2/providers/src/main/java/org/keycloak/services/client/DefaultClientServiceFactory.java
new file mode 100644
index 00000000000..7dec08bf0c2
--- /dev/null
+++ b/rest/admin-v2/providers/src/main/java/org/keycloak/services/client/DefaultClientServiceFactory.java
@@ -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() {
+
+ }
+}
diff --git a/rest/admin-v2/providers/src/main/java/org/keycloak/services/error/ValidationExceptionHandler.java b/rest/admin-v2/providers/src/main/java/org/keycloak/services/error/ValidationExceptionHandler.java
new file mode 100644
index 00000000000..4610ebba131
--- /dev/null
+++ b/rest/admin-v2/providers/src/main/java/org/keycloak/services/error/ValidationExceptionHandler.java
@@ -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 {
+
+ @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();
+ }
+}
diff --git a/rest/admin-v2/providers/src/main/java/org/keycloak/validation/jakarta/HibernateValidatorProvider.java b/rest/admin-v2/providers/src/main/java/org/keycloak/validation/jakarta/HibernateValidatorProvider.java
new file mode 100644
index 00000000000..027ab764a16
--- /dev/null
+++ b/rest/admin-v2/providers/src/main/java/org/keycloak/validation/jakarta/HibernateValidatorProvider.java
@@ -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 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>> 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() {
+
+ }
+}
diff --git a/rest/admin-v2/providers/src/main/java/org/keycloak/validation/jakarta/HibernateValidatorProviderFactory.java b/rest/admin-v2/providers/src/main/java/org/keycloak/validation/jakarta/HibernateValidatorProviderFactory.java
new file mode 100644
index 00000000000..e50f9c5dbba
--- /dev/null
+++ b/rest/admin-v2/providers/src/main/java/org/keycloak/validation/jakarta/HibernateValidatorProviderFactory.java
@@ -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() {
+
+ }
+}
diff --git a/rest/admin-v2/providers/src/main/resources/META-INF/beans.xml b/rest/admin-v2/providers/src/main/resources/META-INF/beans.xml
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/rest/admin-v2/providers/src/main/resources/META-INF/services/org.keycloak.models.mapper.ModelMapperFactory b/rest/admin-v2/providers/src/main/resources/META-INF/services/org.keycloak.models.mapper.ModelMapperFactory
new file mode 100644
index 00000000000..1746852e27c
--- /dev/null
+++ b/rest/admin-v2/providers/src/main/resources/META-INF/services/org.keycloak.models.mapper.ModelMapperFactory
@@ -0,0 +1 @@
+org.keycloak.models.mapper.MapStructModelMapperFactory
\ No newline at end of file
diff --git a/rest/admin-v2/providers/src/main/resources/META-INF/services/org.keycloak.services.client.ClientServiceFactory b/rest/admin-v2/providers/src/main/resources/META-INF/services/org.keycloak.services.client.ClientServiceFactory
new file mode 100644
index 00000000000..9ef09d55c3f
--- /dev/null
+++ b/rest/admin-v2/providers/src/main/resources/META-INF/services/org.keycloak.services.client.ClientServiceFactory
@@ -0,0 +1 @@
+org.keycloak.services.client.DefaultClientServiceFactory
\ No newline at end of file
diff --git a/rest/admin-v2/providers/src/main/resources/META-INF/services/org.keycloak.validation.jakarta.JakartaValidatorProviderFactory b/rest/admin-v2/providers/src/main/resources/META-INF/services/org.keycloak.validation.jakarta.JakartaValidatorProviderFactory
new file mode 100644
index 00000000000..c40b9ff7d96
--- /dev/null
+++ b/rest/admin-v2/providers/src/main/resources/META-INF/services/org.keycloak.validation.jakarta.JakartaValidatorProviderFactory
@@ -0,0 +1 @@
+org.keycloak.validation.jakarta.HibernateValidatorProviderFactory
\ No newline at end of file
diff --git a/rest/admin-v2/rest/pom.xml b/rest/admin-v2/rest/pom.xml
new file mode 100644
index 00000000000..79e382351f9
--- /dev/null
+++ b/rest/admin-v2/rest/pom.xml
@@ -0,0 +1,48 @@
+
+
+ 4.0.0
+
+ org.keycloak
+ keycloak-admin-v2-parent
+ 999.0.0-SNAPSHOT
+
+
+ keycloak-admin-v2-rest
+ Keycloak Admin API v2 REST Layer
+
+
+ 17
+ 17
+ 17
+ UTF-8
+
+
+
+
+ org.keycloak
+ keycloak-core
+
+
+ org.keycloak
+ keycloak-admin-v2-api
+
+
+ org.keycloak
+ keycloak-server-spi
+
+
+ jakarta.ws.rs
+ jakarta.ws.rs-api
+
+
+ org.keycloak
+ keycloak-services
+
+
+ io.fabric8
+ zjsonpatch
+
+
+
\ No newline at end of file
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminApi.java
new file mode 100644
index 00000000000..b0b55f2cdd8
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminApi.java
@@ -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();
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminApiFactory.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminApiFactory.java
new file mode 100644
index 00000000000..c0e44d01504
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminApiFactory.java
@@ -0,0 +1,6 @@
+package org.keycloak.admin.api;
+
+import org.keycloak.provider.ProviderFactory;
+
+public interface AdminApiFactory extends ProviderFactory {
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminApiSpi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminApiSpi.java
new file mode 100644
index 00000000000..154b5949803
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminApiSpi.java
@@ -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> 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
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminRootV2.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminRootV2.java
new file mode 100644
index 00000000000..cafbb09782e
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminRootV2.java
@@ -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
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/DefaultAdminApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/DefaultAdminApi.java
new file mode 100644
index 00000000000..e79450077c4
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/DefaultAdminApi.java
@@ -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() {
+
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/DefaultAdminApiFactory.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/DefaultAdminApiFactory.java
new file mode 100644
index 00000000000..137dffcfa58
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/DefaultAdminApiFactory.java
@@ -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() {
+
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/FieldValidation.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/FieldValidation.java
new file mode 100644
index 00000000000..a8da7f8ed1e
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/FieldValidation.java
@@ -0,0 +1,7 @@
+package org.keycloak.admin.api;
+
+public enum FieldValidation {
+ Ignore,
+ Strict,
+ Warn
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java
new file mode 100644
index 00000000000..ebfd825c590
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java
@@ -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);
+
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApiFactory.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApiFactory.java
new file mode 100644
index 00000000000..eb95f913aae
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApiFactory.java
@@ -0,0 +1,6 @@
+package org.keycloak.admin.api.client;
+
+import org.keycloak.provider.ProviderFactory;
+
+public interface ClientApiFactory extends ProviderFactory {
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApiSpi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApiSpi.java
new file mode 100644
index 00000000000..b246e00dbef
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApiSpi.java
@@ -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> getProviderFactoryClass() {
+ return ClientApiFactory.class;
+ }
+
+ @Override
+ public boolean isInternal() {
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2);
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApi.java
new file mode 100644
index 00000000000..d56e7701895
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApi.java
@@ -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 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);
+}
+
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApiFactory.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApiFactory.java
new file mode 100644
index 00000000000..da3a180474a
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApiFactory.java
@@ -0,0 +1,6 @@
+package org.keycloak.admin.api.client;
+
+import org.keycloak.provider.ProviderFactory;
+
+public interface ClientsApiFactory extends ProviderFactory {
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApiSpi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApiSpi.java
new file mode 100644
index 00000000000..23cf437c135
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApiSpi.java
@@ -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> getProviderFactoryClass() {
+ return ClientsApiFactory.class;
+ }
+
+ @Override
+ public boolean isInternal() {
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2);
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java
new file mode 100644
index 00000000000..d3d8f1e2ab9
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java
@@ -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() {
+
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApiFactory.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApiFactory.java
new file mode 100644
index 00000000000..baf95861c28
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApiFactory.java
@@ -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() {
+
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientsApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientsApi.java
new file mode 100644
index 00000000000..e8e4b7cc3f5
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientsApi.java
@@ -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 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() {
+
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientsApiFactory.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientsApiFactory.java
new file mode 100644
index 00000000000..931fe3af6ba
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientsApiFactory.java
@@ -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() {
+
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmApi.java
new file mode 100644
index 00000000000..00b3181f7ab
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmApi.java
@@ -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() {}
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmApiFactory.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmApiFactory.java
new file mode 100644
index 00000000000..a231426c480
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmApiFactory.java
@@ -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() {
+
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmsApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmsApi.java
new file mode 100644
index 00000000000..d673e9b8fcd
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmsApi.java
@@ -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() {
+
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmsApiFactory.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmsApiFactory.java
new file mode 100644
index 00000000000..c8a1a7370c1
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmsApiFactory.java
@@ -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() {
+
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmApi.java
new file mode 100644
index 00000000000..0074acad205
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmApi.java
@@ -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();
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmApiFactory.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmApiFactory.java
new file mode 100644
index 00000000000..54a4835530d
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmApiFactory.java
@@ -0,0 +1,6 @@
+package org.keycloak.admin.api.realm;
+
+import org.keycloak.provider.ProviderFactory;
+
+public interface RealmApiFactory extends ProviderFactory {
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmApiSpi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmApiSpi.java
new file mode 100644
index 00000000000..6c692bc39fa
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmApiSpi.java
@@ -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> getProviderFactoryClass() {
+ return RealmApiFactory.class;
+ }
+
+ @Override
+ public boolean isInternal() {
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return isAdminApiV2Enabled();
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmsApi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmsApi.java
new file mode 100644
index 00000000000..92a26a5b2d0
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmsApi.java
@@ -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);
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmsApiFactory.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmsApiFactory.java
new file mode 100644
index 00000000000..aff3c8a2fd2
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmsApiFactory.java
@@ -0,0 +1,6 @@
+package org.keycloak.admin.api.realm;
+
+import org.keycloak.provider.ProviderFactory;
+
+public interface RealmsApiFactory extends ProviderFactory {
+}
diff --git a/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmsApiSpi.java b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmsApiSpi.java
new file mode 100644
index 00000000000..b26cce77cc3
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmsApiSpi.java
@@ -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> getProviderFactoryClass() {
+ return RealmsApiFactory.class;
+ }
+
+ @Override
+ public boolean isInternal() {
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return isAdminApiV2Enabled();
+ }
+}
diff --git a/rest/admin-v2/rest/src/main/resources/META-INF/beans.xml b/rest/admin-v2/rest/src/main/resources/META-INF/beans.xml
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.AdminApiFactory b/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.AdminApiFactory
new file mode 100644
index 00000000000..14f064e7d7e
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.AdminApiFactory
@@ -0,0 +1 @@
+org.keycloak.admin.api.DefaultAdminApiFactory
\ No newline at end of file
diff --git a/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.client.ClientApiFactory b/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.client.ClientApiFactory
new file mode 100644
index 00000000000..310fd11a655
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.client.ClientApiFactory
@@ -0,0 +1 @@
+org.keycloak.admin.api.client.DefaultClientApiFactory
\ No newline at end of file
diff --git a/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.client.ClientsApiFactory b/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.client.ClientsApiFactory
new file mode 100644
index 00000000000..9a5198083ae
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.client.ClientsApiFactory
@@ -0,0 +1 @@
+org.keycloak.admin.api.client.DefaultClientsApiFactory
\ No newline at end of file
diff --git a/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.realm.RealmApiFactory b/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.realm.RealmApiFactory
new file mode 100644
index 00000000000..31e0a0ea889
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.realm.RealmApiFactory
@@ -0,0 +1 @@
+org.keycloak.admin.api.realm.DefaultRealmApiFactory
\ No newline at end of file
diff --git a/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.realm.RealmsApiFactory b/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.realm.RealmsApiFactory
new file mode 100644
index 00000000000..d37d3d5a413
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.realm.RealmsApiFactory
@@ -0,0 +1 @@
+org.keycloak.admin.api.realm.DefaultRealmsApiFactory
\ No newline at end of file
diff --git a/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.provider.Spi
new file mode 100644
index 00000000000..d84d8e9be37
--- /dev/null
+++ b/rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.provider.Spi
@@ -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
\ No newline at end of file
diff --git a/rest/admin-v2/tests/pom.xml b/rest/admin-v2/tests/pom.xml
new file mode 100644
index 00000000000..0d4ca870ea4
--- /dev/null
+++ b/rest/admin-v2/tests/pom.xml
@@ -0,0 +1,55 @@
+
+
+ 4.0.0
+
+ org.keycloak
+ keycloak-admin-v2-parent
+ 999.0.0-SNAPSHOT
+
+
+ keycloak-admin-v2-tests
+ Keycloak Admin API v2 Tests
+
+
+
+
+ org.keycloak.testframework
+ keycloak-test-framework-bom
+ ${project.version}
+ import
+ pom
+
+
+
+
+
+
+ org.keycloak.testframework
+ keycloak-test-framework-core
+
+
+ org.keycloak.testframework
+ keycloak-test-framework-junit5-config
+
+
+ org.keycloak
+ keycloak-admin-v2-rest
+
+
+
+
+
+
+ maven-surefire-plugin
+
+
+ org.jboss.logmanager.LogManager
+ io.quarkus.bootstrap.forkjoin.QuarkusForkJoinWorkerThreadFactory
+
+
+
+
+
+
\ No newline at end of file
diff --git a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2DisabledTest.java b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2DisabledTest.java
new file mode 100644
index 00000000000..4dd5ba2a4f8
--- /dev/null
+++ b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2DisabledTest.java
@@ -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
+ */
+@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());
+ }
+ }
+}
diff --git a/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java
new file mode 100644
index 00000000000..694097b9d68
--- /dev/null
+++ b/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java
@@ -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;
+ }
+ }
+}
diff --git a/rest/admin-v2/tests/src/test/resources/keycloak-test.properties b/rest/admin-v2/tests/src/test/resources/keycloak-test.properties
new file mode 100644
index 00000000000..70711117426
--- /dev/null
+++ b/rest/admin-v2/tests/src/test/resources/keycloak-test.properties
@@ -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
\ No newline at end of file
diff --git a/rest/pom.xml b/rest/pom.xml
index c4d975748c0..37f049de7e7 100644
--- a/rest/pom.xml
+++ b/rest/pom.xml
@@ -32,6 +32,7 @@
pom
+ admin-v2
admin-ui-ext
diff --git a/services/pom.xml b/services/pom.xml
index 26e092364b7..fe99d0d5c6c 100755
--- a/services/pom.xml
+++ b/services/pom.xml
@@ -81,7 +81,11 @@
org.twitter4j
twitter4j-core
-
+
+ org.hibernate.validator
+ hibernate-validator
+ ${hibernate-validator.version}
+
org.jboss.logging
jboss-logging
diff --git a/services/src/main/java/org/keycloak/services/error/KeycloakErrorHandler.java b/services/src/main/java/org/keycloak/services/error/KeycloakErrorHandler.java
index 4f5decdb18f..abe70519237 100644
--- a/services/src/main/java/org/keycloak/services/error/KeycloakErrorHandler.java
+++ b/services/src/main/java/org/keycloak/services/error/KeycloakErrorHandler.java
@@ -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 {
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)
diff --git a/services/src/main/java/org/keycloak/services/resources/KeycloakOpenAPI.java b/services/src/main/java/org/keycloak/services/resources/KeycloakOpenAPI.java
index 557be863587..9bb9f744617 100644
--- a/services/src/main/java/org/keycloak/services/resources/KeycloakOpenAPI.java
+++ b/services/src/main/java/org/keycloak/services/resources/KeycloakOpenAPI.java
@@ -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";
diff --git a/services/src/main/java/org/keycloak/services/resources/admin/AdminRoot.java b/services/src/main/java/org/keycloak/services/resources/admin/AdminRoot.java
index 136f0d40af8..71d24a143e8 100755
--- a/services/src/main/java/org/keycloak/services/resources/admin/AdminRoot.java
+++ b/services/src/main/java/org/keycloak/services/resources/admin/AdminRoot.java
@@ -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();
}
diff --git a/services/src/test/java/org/keycloak/compatibility/FeatureCompatibilityMetadataProviderTest.java b/services/src/test/java/org/keycloak/compatibility/FeatureCompatibilityMetadataProviderTest.java
index 3df861950cb..7ede143871a 100644
--- a/services/src/test/java/org/keycloak/compatibility/FeatureCompatibilityMetadataProviderTest.java
+++ b/services/src/test/java/org/keycloak/compatibility/FeatureCompatibilityMetadataProviderTest.java
@@ -192,6 +192,7 @@ public class FeatureCompatibilityMetadataProviderTest extends AbstractCompatibil
@Override
public FeatureConfig getFeatureConfig(String featureName) {
+ // No support for transitive dependencies but that should be fine for now
if (enabled) {
if (DEPENDENT_FEATURES.containsKey(feature)) {
for (Profile.Feature dep : DEPENDENT_FEATURES.get(feature)) {
@@ -199,6 +200,10 @@ public class FeatureCompatibilityMetadataProviderTest extends AbstractCompatibil
return FeatureConfig.ENABLED;
}
}
+ for (Profile.Feature dep : feature.getDependencies()) { // Explicitly enable dependencies that might be disabled by default
+ if (dep.getVersionedKey().equals(featureName))
+ return FeatureConfig.ENABLED;
+ }
return feature.getVersionedKey().equals(featureName) ? FeatureConfig.ENABLED : FeatureConfig.UNCONFIGURED;
} else {
if (DEPENDENT_FEATURES.containsKey(feature)) {
diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/server/KeycloakServerConfigBuilder.java b/test-framework/core/src/main/java/org/keycloak/testframework/server/KeycloakServerConfigBuilder.java
index 7fc8799ffe2..541d9ac2a0a 100644
--- a/test-framework/core/src/main/java/org/keycloak/testframework/server/KeycloakServerConfigBuilder.java
+++ b/test-framework/core/src/main/java/org/keycloak/testframework/server/KeycloakServerConfigBuilder.java
@@ -251,10 +251,10 @@ public class KeycloakServerConfigBuilder {
private Set toFeatureStrings(Profile.Feature... features) {
return Arrays.stream(features).map(f -> {
- if (Profile.getFeatureVersions(f.getKey()).size() > 1) {
+ if (f.getVersion() > 1 || Profile.getFeatureVersions(f.getKey()).size() > 1) {
return f.getVersionedKey();
}
- return f.name().toLowerCase().replace('_', '-');
+ return f.getUnversionedKey();
}).collect(Collectors.toSet());
}