From 9c86eae7eddd94872a0977f129ee5f6d3a829485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Muzik=C3=A1=C5=99?= Date: Mon, 3 Nov 2025 14:31:54 +0100 Subject: [PATCH] Initial Client API v2 impl (#43395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #43224 Signed-off-by: Václav Muzikář Co-authored-by: Martin Bartoš Co-authored-by: Peter Zaoral Co-authored-by: Steven Hawkins Co-authored-by: Robin Meese <39960884+robson90@users.noreply.github.com> --- .github/actions/conditional/action.yml | 3 + .github/actions/conditional/conditions | 2 + .../scripts/find-modules-with-unit-tests.sh | 2 +- .github/workflows/ci.yml | 20 ++ .../java/org/keycloak/common/Profile.java | 4 + pom.xml | 28 ++ .../org/keycloak/config/OpenApiOptions.java | 17 ++ .../org/keycloak/config/OptionCategory.java | 1 + quarkus/deployment/pom.xml | 12 + quarkus/runtime/pom.xml | 58 ++++- .../mappers/ManagementPropertyMappers.java | 7 +- .../mappers/OpenApiPropertyMappers.java | 34 +++ .../mappers/PropertyMappers.java | 2 +- .../quarkus/runtime/oas/OASModelFilter.java | 99 +++++++ .../src/main/resources/application.properties | 13 + quarkus/server/pom.xml | 5 + .../keycloak/it/cli/dist/OpenApiDistTest.java | 89 +++++++ ...andDistTest.testExportHelpAll.approved.txt | 11 + ...andDistTest.testImportHelpAll.approved.txt | 11 + ...dDistTest.testStartDevHelpAll.approved.txt | 11 + ...mandDistTest.testStartHelpAll.approved.txt | 11 + ...dateCompatibilityCheckHelpAll.approved.txt | 11 + ...eCompatibilityMetadataHelpAll.approved.txt | 11 + rest/admin-v2/api/pom.xml | 44 ++++ .../models/mapper/ClientModelMapper.java | 12 + .../keycloak/models/mapper/ModelMapper.java | 11 + .../models/mapper/ModelMapperFactory.java | 6 + .../models/mapper/ModelMapperSpi.java | 35 +++ .../admin/v2/BaseRepresentation.java | 31 +++ .../admin/v2/ClientRepresentation.java | 244 ++++++++++++++++++ .../admin/v2/validation/CreateClient.java | 5 + .../v2/validation/CreateClientDefault.java | 9 + .../java/org/keycloak/services/Service.java | 9 + .../keycloak/services/ServiceException.java | 27 ++ .../services/client/ClientService.java | 40 +++ .../services/client/ClientServiceFactory.java | 6 + .../services/client/ClientServiceSpi.java | 34 +++ .../error/ViolationExceptionResponse.java | 6 + .../jakarta/JakartaValidatorProvider.java | 18 ++ .../JakartaValidatorProviderFactory.java | 6 + .../jakarta/JakartaValidatorSpi.java | 34 +++ .../services/org.keycloak.provider.Spi | 3 + rest/admin-v2/pom.xml | 22 ++ rest/admin-v2/providers/pom.xml | 61 +++++ .../mapper/MapStructClientModelMapper.java | 45 ++++ .../models/mapper/MapStructModelMapper.java | 16 ++ .../mapper/MapStructModelMapperFactory.java | 38 +++ .../services/client/DefaultClientService.java | 83 ++++++ .../client/DefaultClientServiceFactory.java | 34 +++ .../error/ValidationExceptionHandler.java | 25 ++ .../jakarta/HibernateValidatorProvider.java | 42 +++ .../HibernateValidatorProviderFactory.java | 40 +++ .../src/main/resources/META-INF/beans.xml | 0 ....keycloak.models.mapper.ModelMapperFactory | 1 + ...cloak.services.client.ClientServiceFactory | 1 + ...on.jakarta.JakartaValidatorProviderFactory | 1 + rest/admin-v2/rest/pom.xml | 48 ++++ .../java/org/keycloak/admin/api/AdminApi.java | 11 + .../keycloak/admin/api/AdminApiFactory.java | 6 + .../org/keycloak/admin/api/AdminApiSpi.java | 35 +++ .../org/keycloak/admin/api/AdminRootV2.java | 50 ++++ .../keycloak/admin/api/DefaultAdminApi.java | 37 +++ .../admin/api/DefaultAdminApiFactory.java | 34 +++ .../keycloak/admin/api/FieldValidation.java | 7 + .../keycloak/admin/api/client/ClientApi.java | 38 +++ .../admin/api/client/ClientApiFactory.java | 6 + .../admin/api/client/ClientApiSpi.java | 35 +++ .../keycloak/admin/api/client/ClientsApi.java | 43 +++ .../admin/api/client/ClientsApiFactory.java | 6 + .../admin/api/client/ClientsApiSpi.java | 35 +++ .../admin/api/client/DefaultClientApi.java | 106 ++++++++ .../api/client/DefaultClientApiFactory.java | 34 +++ .../admin/api/client/DefaultClientsApi.java | 65 +++++ .../api/client/DefaultClientsApiFactory.java | 34 +++ .../admin/api/realm/DefaultRealmApi.java | 27 ++ .../api/realm/DefaultRealmApiFactory.java | 34 +++ .../admin/api/realm/DefaultRealmsApi.java | 29 +++ .../api/realm/DefaultRealmsApiFactory.java | 34 +++ .../keycloak/admin/api/realm/RealmApi.java | 11 + .../admin/api/realm/RealmApiFactory.java | 6 + .../keycloak/admin/api/realm/RealmApiSpi.java | 36 +++ .../keycloak/admin/api/realm/RealmsApi.java | 12 + .../admin/api/realm/RealmsApiFactory.java | 6 + .../admin/api/realm/RealmsApiSpi.java | 36 +++ .../src/main/resources/META-INF/beans.xml | 0 .../org.keycloak.admin.api.AdminApiFactory | 1 + ...keycloak.admin.api.client.ClientApiFactory | 1 + ...eycloak.admin.api.client.ClientsApiFactory | 1 + ...g.keycloak.admin.api.realm.RealmApiFactory | 1 + ....keycloak.admin.api.realm.RealmsApiFactory | 1 + .../services/org.keycloak.provider.Spi | 5 + rest/admin-v2/tests/pom.xml | 55 ++++ .../client/v2/ClientApiV2DisabledTest.java | 27 ++ .../admin/client/v2/ClientApiV2Test.java | 211 +++++++++++++++ .../test/resources/keycloak-test.properties | 10 + rest/pom.xml | 1 + services/pom.xml | 6 +- .../services/error/KeycloakErrorHandler.java | 3 + .../services/resources/KeycloakOpenAPI.java | 1 + .../services/resources/admin/AdminRoot.java | 8 +- ...tureCompatibilityMetadataProviderTest.java | 5 + .../server/KeycloakServerConfigBuilder.java | 4 +- 102 files changed, 2641 insertions(+), 12 deletions(-) create mode 100644 quarkus/config-api/src/main/java/org/keycloak/config/OpenApiOptions.java create mode 100644 quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/configuration/mappers/OpenApiPropertyMappers.java create mode 100644 quarkus/runtime/src/main/java/org/keycloak/quarkus/runtime/oas/OASModelFilter.java create mode 100644 quarkus/tests/integration/src/test/java/org/keycloak/it/cli/dist/OpenApiDistTest.java create mode 100644 rest/admin-v2/api/pom.xml create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ClientModelMapper.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapper.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapperFactory.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/models/mapper/ModelMapperSpi.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/BaseRepresentation.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/ClientRepresentation.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/validation/CreateClient.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/representations/admin/v2/validation/CreateClientDefault.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/services/Service.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/services/ServiceException.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientService.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientServiceFactory.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/services/client/ClientServiceSpi.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/services/error/ViolationExceptionResponse.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/validation/jakarta/JakartaValidatorProvider.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/validation/jakarta/JakartaValidatorProviderFactory.java create mode 100644 rest/admin-v2/api/src/main/java/org/keycloak/validation/jakarta/JakartaValidatorSpi.java create mode 100644 rest/admin-v2/api/src/main/resources/META-INF/services/org.keycloak.provider.Spi create mode 100644 rest/admin-v2/pom.xml create mode 100644 rest/admin-v2/providers/pom.xml create mode 100644 rest/admin-v2/providers/src/main/java/org/keycloak/models/mapper/MapStructClientModelMapper.java create mode 100644 rest/admin-v2/providers/src/main/java/org/keycloak/models/mapper/MapStructModelMapper.java create mode 100644 rest/admin-v2/providers/src/main/java/org/keycloak/models/mapper/MapStructModelMapperFactory.java create mode 100644 rest/admin-v2/providers/src/main/java/org/keycloak/services/client/DefaultClientService.java create mode 100644 rest/admin-v2/providers/src/main/java/org/keycloak/services/client/DefaultClientServiceFactory.java create mode 100644 rest/admin-v2/providers/src/main/java/org/keycloak/services/error/ValidationExceptionHandler.java create mode 100644 rest/admin-v2/providers/src/main/java/org/keycloak/validation/jakarta/HibernateValidatorProvider.java create mode 100644 rest/admin-v2/providers/src/main/java/org/keycloak/validation/jakarta/HibernateValidatorProviderFactory.java create mode 100644 rest/admin-v2/providers/src/main/resources/META-INF/beans.xml create mode 100644 rest/admin-v2/providers/src/main/resources/META-INF/services/org.keycloak.models.mapper.ModelMapperFactory create mode 100644 rest/admin-v2/providers/src/main/resources/META-INF/services/org.keycloak.services.client.ClientServiceFactory create mode 100644 rest/admin-v2/providers/src/main/resources/META-INF/services/org.keycloak.validation.jakarta.JakartaValidatorProviderFactory create mode 100644 rest/admin-v2/rest/pom.xml create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminApi.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminApiFactory.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminApiSpi.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/AdminRootV2.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/DefaultAdminApi.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/DefaultAdminApiFactory.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/FieldValidation.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApi.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApiFactory.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientApiSpi.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApi.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApiFactory.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/ClientsApiSpi.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApi.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientApiFactory.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientsApi.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/client/DefaultClientsApiFactory.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmApi.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmApiFactory.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmsApi.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/DefaultRealmsApiFactory.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmApi.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmApiFactory.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmApiSpi.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmsApi.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmsApiFactory.java create mode 100644 rest/admin-v2/rest/src/main/java/org/keycloak/admin/api/realm/RealmsApiSpi.java create mode 100644 rest/admin-v2/rest/src/main/resources/META-INF/beans.xml create mode 100644 rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.AdminApiFactory create mode 100644 rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.client.ClientApiFactory create mode 100644 rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.client.ClientsApiFactory create mode 100644 rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.realm.RealmApiFactory create mode 100644 rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.admin.api.realm.RealmsApiFactory create mode 100644 rest/admin-v2/rest/src/main/resources/META-INF/services/org.keycloak.provider.Spi create mode 100644 rest/admin-v2/tests/pom.xml create mode 100644 rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2DisabledTest.java create mode 100644 rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java create mode 100644 rest/admin-v2/tests/src/test/resources/keycloak-test.properties 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> 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 getProviderClass() { + return ModelMapper.class; + } + + @Override + public Class> 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 getProviderClass() { + return ClientService.class; + } + + @Override + public Class> 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 getProviderClass() { + return JakartaValidatorProvider.class; + } + + @Override + public Class> 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 getProviderClass() { + return AdminApi.class; + } + + @Override + public Class> 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 getProviderClass() { + return ClientApi.class; + } + + @Override + public Class> 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 getProviderClass() { + return ClientsApi.class; + } + + @Override + public Class> 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 getProviderClass() { + return RealmApi.class; + } + + @Override + public Class> 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 getProviderClass() { + return RealmsApi.class; + } + + @Override + public Class> 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()); }