This is an automated email from the ASF dual-hosted git repository.
jamesnetherton pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git
The following commit(s) were added to refs/heads/main by this push:
new f07b813a21 camel-keycloak - add test and native support for camel 4.17
features in CEQ
f07b813a21 is described below
commit f07b813a21fdbff5633cec8b57e1b8d2295a1734
Author: JinyuChen97 <[email protected]>
AuthorDate: Fri Feb 27 13:00:22 2026 +0000
camel-keycloak - add test and native support for camel 4.17 features in CEQ
Fixes #8091
---
extensions/keycloak/deployment/pom.xml | 4 +
.../keycloak/deployment/KeycloakProcessor.java | 43 ++
extensions/keycloak/runtime/pom.xml | 4 +
integration-tests/keycloak/pom.xml | 17 +
.../it/KeycloakEvaluatePermissionResource.java | 195 ++++++++++
.../keycloak/it/KeycloakRouteBuilder.java | 150 +++++++
.../it/KeycloakSecurityPolicyResource.java | 337 ++++++++++++++++
.../keycloak/it/KeycloakUserResource.java | 116 ++++++
.../src/main/resources/application.properties | 2 +
.../it/KeycloakEvaluatePermissionTest.java | 331 ++++++++++++++++
.../it/KeycloakEvaluatePermissionTestIT.java | 23 +-
.../component/keycloak/it/KeycloakRoleTest.java | 87 ++++-
.../keycloak/it/KeycloakSecurityPolicyIT.java | 24 +-
.../keycloak/it/KeycloakSecurityPolicyTest.java | 432 +++++++++++++++++++++
.../it/KeycloakSecurityPolicyTestBase.java | 36 ++
.../component/keycloak/it/KeycloakTestBase.java | 42 ++
.../keycloak/it/KeycloakTestResource.java | 4 +
.../component/keycloak/it/KeycloakUserTest.java | 164 +++++++-
18 files changed, 1970 insertions(+), 41 deletions(-)
diff --git a/extensions/keycloak/deployment/pom.xml
b/extensions/keycloak/deployment/pom.xml
index 23115671ef..58cc3e02d9 100644
--- a/extensions/keycloak/deployment/pom.xml
+++ b/extensions/keycloak/deployment/pom.xml
@@ -47,6 +47,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-keycloak-admin-resteasy-client-deployment</artifactId>
</dependency>
+ <dependency>
+ <groupId>io.quarkus</groupId>
+ <artifactId>quarkus-caffeine-deployment</artifactId>
+ </dependency>
</dependencies>
<build>
diff --git
a/extensions/keycloak/deployment/src/main/java/org/apache/camel/quarkus/component/keycloak/deployment/KeycloakProcessor.java
b/extensions/keycloak/deployment/src/main/java/org/apache/camel/quarkus/component/keycloak/deployment/KeycloakProcessor.java
index 85530325c0..8d5fb696a4 100644
---
a/extensions/keycloak/deployment/src/main/java/org/apache/camel/quarkus/component/keycloak/deployment/KeycloakProcessor.java
+++
b/extensions/keycloak/deployment/src/main/java/org/apache/camel/quarkus/component/keycloak/deployment/KeycloakProcessor.java
@@ -16,15 +16,28 @@
*/
package org.apache.camel.quarkus.component.keycloak.deployment;
+import java.util.function.BooleanSupplier;
+
import io.quarkus.deployment.annotations.BuildProducer;
import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.builditem.FeatureBuildItem;
+import
io.quarkus.deployment.builditem.nativeimage.NativeImageSystemPropertyBuildItem;
+import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem;
import
io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem;
+import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem;
+import org.apache.camel.quarkus.core.deployment.util.CamelSupport;
+import org.keycloak.common.crypto.CryptoIntegration;
import org.keycloak.common.util.BouncyIntegration;
+import static
io.quarkus.caffeine.runtime.graal.CacheConstructorsFeature.REGISTER_RECORD_STATS_IMPLEMENTATIONS;
+
class KeycloakProcessor {
private static final String FEATURE = "camel-keycloak";
+ private static final String[] SERVICE_PROVIDER_SPIS = {
+ "org.keycloak.common.crypto.CryptoProvider",
+
"org.keycloak.protocol.oidc.client.authentication.ClientCredentialsProvider"
+ };
@BuildStep
FeatureBuildItem feature() {
@@ -34,6 +47,36 @@ class KeycloakProcessor {
@BuildStep
void
runtimeInitializedClasses(BuildProducer<RuntimeInitializedClassBuildItem>
runtimeInitializedClass) {
runtimeInitializedClass.produce(new
RuntimeInitializedClassBuildItem(BouncyIntegration.class.getName()));
+ runtimeInitializedClass.produce(new
RuntimeInitializedClassBuildItem(CryptoIntegration.class.getName()));
+ }
+
+ @BuildStep
+ void registerServiceProviders(BuildProducer<ServiceProviderBuildItem>
serviceProvider) {
+ for (String spi : SERVICE_PROVIDER_SPIS) {
+
serviceProvider.produce(ServiceProviderBuildItem.allProvidersFromClassPath(spi));
+ }
+ }
+
+ @BuildStep
+ void registerForReflection(BuildProducer<ReflectiveClassBuildItem>
reflectiveClass) {
+ reflectiveClass.produce(ReflectiveClassBuildItem.builder(
+ org.keycloak.jose.jws.JWSHeader.class,
+ org.keycloak.jose.jws.JWSInput.class,
+
org.keycloak.authorization.client.representation.ServerConfiguration.class)
+ .methods().fields().build());
+ }
+
+ @BuildStep(onlyIf = CamelCaffeineStatsEnabled.class)
+ NativeImageSystemPropertyBuildItem registerRecordStatsImplementations() {
+ return new
NativeImageSystemPropertyBuildItem(REGISTER_RECORD_STATS_IMPLEMENTATIONS,
"true");
+ }
+
+ static final class CamelCaffeineStatsEnabled implements BooleanSupplier {
+ @Override
+ public boolean getAsBoolean() {
+ return
CamelSupport.getOptionalConfigValue("camel.component.caffeine-cache.stats-enabled",
boolean.class, false) ||
+
CamelSupport.getOptionalConfigValue("camel.component.caffeine-cache.statsEnabled",
boolean.class, false);
+ }
}
}
diff --git a/extensions/keycloak/runtime/pom.xml
b/extensions/keycloak/runtime/pom.xml
index e5ed3c2e1e..86731f5392 100644
--- a/extensions/keycloak/runtime/pom.xml
+++ b/extensions/keycloak/runtime/pom.xml
@@ -54,6 +54,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-keycloak-admin-resteasy-client</artifactId>
</dependency>
+ <dependency>
+ <groupId>io.quarkus</groupId>
+ <artifactId>quarkus-caffeine</artifactId>
+ </dependency>
</dependencies>
<build>
diff --git a/integration-tests/keycloak/pom.xml
b/integration-tests/keycloak/pom.xml
index 93f628abe3..366f1fa07c 100644
--- a/integration-tests/keycloak/pom.xml
+++ b/integration-tests/keycloak/pom.xml
@@ -35,6 +35,10 @@
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-keycloak</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-direct</artifactId>
+ </dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-mock</artifactId>
@@ -98,6 +102,19 @@
</activation>
<dependencies>
<!-- The following dependencies guarantee that this module is
built after them. You can update them by running `mvn process-resources
-Pformat -N` from the source tree root directory -->
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-direct-deployment</artifactId>
+ <version>${project.version}</version>
+ <type>pom</type>
+ <scope>test</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>*</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-keycloak-deployment</artifactId>
diff --git
a/integration-tests/keycloak/src/main/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakEvaluatePermissionResource.java
b/integration-tests/keycloak/src/main/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakEvaluatePermissionResource.java
new file mode 100644
index 0000000000..da927f7431
--- /dev/null
+++
b/integration-tests/keycloak/src/main/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakEvaluatePermissionResource.java
@@ -0,0 +1,195 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.camel.quarkus.component.keycloak.it;
+
+import java.util.Map;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import org.apache.camel.Exchange;
+import org.apache.camel.component.keycloak.KeycloakConstants;
+
+@Path("/keycloak/evaluate-permission")
+@ApplicationScoped
+public class KeycloakEvaluatePermissionResource extends
KeycloakResourceSupport {
+
+ /**
+ * permissionsOnly mode: sets CamelKeycloakPermissionsOnly=true
+ * Returns Map with keys: permissions, permissionCount, granted
+ */
+ @Path("/permissions-only")
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response testEvaluatePermissionWithPermissionsOnly(
+ @QueryParam("clientId") String clientId,
+ @QueryParam("clientSecret") String clientSecret,
+ @QueryParam("accessToken") String accessToken,
+ @QueryParam("resourceNames") String resourceNames,
+ @QueryParam("scopes") String scopes) {
+
+ Exchange exchange = producerTemplate.send("direct:evaluatePermission",
e -> {
+ e.getIn().setHeader("X-Authz-Client-Id", clientId);
+ e.getIn().setHeader("X-Authz-Client-Secret", clientSecret);
+ e.getIn().setHeader(KeycloakConstants.ACCESS_TOKEN, accessToken);
+ e.getIn().setHeader(KeycloakConstants.PERMISSIONS_ONLY,
Boolean.TRUE);
+ if (resourceNames != null) {
+
e.getIn().setHeader(KeycloakConstants.PERMISSION_RESOURCE_NAMES, resourceNames);
+ }
+ if (scopes != null) {
+ e.getIn().setHeader(KeycloakConstants.PERMISSION_SCOPES,
scopes);
+ }
+ });
+
+ if (exchange.getException() != null) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(exchange.getException().getMessage())
+ .build();
+ }
+ return Response.ok(exchange.getMessage().getBody(Map.class)).build();
+ }
+
+ /**
+ * RPT mode: PERMISSIONS_ONLY header deliberately omitted
+ * Returns Map with keys: token, tokenType, expiresIn, refreshToken,
refreshExpiresIn, upgraded.
+ */
+ @Path("/rpt")
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response testEvaluatePermissionWithRPT(
+ @QueryParam("clientId") String clientId,
+ @QueryParam("clientSecret") String clientSecret,
+ @QueryParam("accessToken") String accessToken,
+ @QueryParam("resourceNames") String resourceNames) {
+
+ Exchange exchange = producerTemplate.send("direct:evaluatePermission",
e -> {
+ e.getIn().setHeader("X-Authz-Client-Id", clientId);
+ e.getIn().setHeader("X-Authz-Client-Secret", clientSecret);
+ e.getIn().setHeader(KeycloakConstants.ACCESS_TOKEN, accessToken);
+ // if no PERMISSIONS_ONLY in header then go RPT
+ if (resourceNames != null) {
+
e.getIn().setHeader(KeycloakConstants.PERMISSION_RESOURCE_NAMES, resourceNames);
+ }
+ });
+
+ if (exchange.getException() != null) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(exchange.getException().getMessage())
+ .build();
+ }
+ return Response.ok(exchange.getMessage().getBody(Map.class)).build();
+ }
+
+ /**
+ * test evaluate permission with username and password
+ */
+ @Path("/username-password")
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response testEvaluatePermissionWithUsernameAndPassword(
+ @QueryParam("clientId") String clientId,
+ @QueryParam("clientSecret") String clientSecret,
+ @QueryParam("username") String username,
+ @QueryParam("password") String password,
+ @QueryParam("resourceNames") String resourceNames) {
+
+ Exchange exchange =
producerTemplate.send("direct:evaluatePermissionUserPass", e -> {
+ e.getIn().setHeader("X-Authz-Client-Id", clientId);
+ e.getIn().setHeader("X-Authz-Client-Secret", clientSecret);
+ e.getIn().setHeader("X-Authz-Username", username);
+ e.getIn().setHeader("X-Authz-Password", password);
+ e.getIn().setHeader(KeycloakConstants.PERMISSIONS_ONLY,
Boolean.TRUE);
+ if (resourceNames != null) {
+
e.getIn().setHeader(KeycloakConstants.PERMISSION_RESOURCE_NAMES, resourceNames);
+ }
+ });
+
+ if (exchange.getException() != null) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(exchange.getException().getMessage())
+ .build();
+ }
+ return Response.ok(exchange.getMessage().getBody(Map.class)).build();
+ }
+
+ /**
+ * test SUBJECT_TOKEN header configuration
+ */
+ @Path("/subject-token")
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response subjectToken(
+ @QueryParam("clientId") String clientId,
+ @QueryParam("clientSecret") String clientSecret,
+ @QueryParam("subjectToken") String subjectToken,
+ @QueryParam("resourceNames") String resourceNames) {
+
+ Exchange exchange = producerTemplate.send("direct:evaluatePermission",
e -> {
+ e.getIn().setHeader("X-Authz-Client-Id", clientId);
+ e.getIn().setHeader("X-Authz-Client-Secret", clientSecret);
+ e.getIn().setHeader(KeycloakConstants.SUBJECT_TOKEN, subjectToken);
+ e.getIn().setHeader(KeycloakConstants.PERMISSIONS_ONLY,
Boolean.TRUE);
+ if (resourceNames != null) {
+
e.getIn().setHeader(KeycloakConstants.PERMISSION_RESOURCE_NAMES, resourceNames);
+ }
+ });
+
+ if (exchange.getException() != null) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(exchange.getException().getMessage())
+ .build();
+ }
+ return Response.ok(exchange.getMessage().getBody(Map.class)).build();
+ }
+
+ /**
+ * test PERMISSION_AUDIENCE header configuration
+ */
+ @Path("/audience")
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response audience(
+ @QueryParam("clientId") String clientId,
+ @QueryParam("clientSecret") String clientSecret,
+ @QueryParam("accessToken") String accessToken,
+ @QueryParam("audience") String audience,
+ @QueryParam("resourceNames") String resourceNames) {
+
+ Exchange exchange = producerTemplate.send("direct:evaluatePermission",
e -> {
+ e.getIn().setHeader("X-Authz-Client-Id", clientId);
+ e.getIn().setHeader("X-Authz-Client-Secret", clientSecret);
+ e.getIn().setHeader(KeycloakConstants.ACCESS_TOKEN, accessToken);
+ e.getIn().setHeader(KeycloakConstants.PERMISSION_AUDIENCE,
audience);
+ e.getIn().setHeader(KeycloakConstants.PERMISSIONS_ONLY,
Boolean.TRUE);
+ if (resourceNames != null) {
+
e.getIn().setHeader(KeycloakConstants.PERMISSION_RESOURCE_NAMES, resourceNames);
+ }
+ });
+
+ if (exchange.getException() != null) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(exchange.getException().getMessage())
+ .build();
+ }
+ return Response.ok(exchange.getMessage().getBody(Map.class)).build();
+ }
+}
diff --git
a/integration-tests/keycloak/src/main/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakRouteBuilder.java
b/integration-tests/keycloak/src/main/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakRouteBuilder.java
new file mode 100644
index 0000000000..00fa81ae0e
--- /dev/null
+++
b/integration-tests/keycloak/src/main/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakRouteBuilder.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.camel.quarkus.component.keycloak.it;
+
+import java.util.List;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.keycloak.security.KeycloakSecurityPolicy;
+import org.eclipse.microprofile.config.ConfigProvider;
+
+@ApplicationScoped
+public class KeycloakRouteBuilder extends RouteBuilder {
+ @Override
+ public void configure() {
+ // Values provided by KeycloakTestResource
+ var config = ConfigProvider.getConfig();
+
+ String serverUrl = config.getValue("keycloak.url", String.class);
+ String realm = config.getValue("test.realm", String.class);
+ String clientId = config.getValue("test.client.id", String.class);
+ String clientSecret = config.getValue("test.client.secret",
String.class);
+
+ // Route 1: secure default
+ KeycloakSecurityPolicy secureDefaultPolicy = new
KeycloakSecurityPolicy();
+ secureDefaultPolicy.setServerUrl(serverUrl);
+ secureDefaultPolicy.setRealm(realm);
+ secureDefaultPolicy.setClientId(clientId);
+ secureDefaultPolicy.setClientSecret(clientSecret);
+ secureDefaultPolicy.setValidateTokenBinding(true);
+ secureDefaultPolicy.setAllowTokenFromHeader(true);
+ secureDefaultPolicy.setPreferPropertyOverHeader(true);
+
+ from("direct:secure-default")
+ .routeId("secure-default")
+ .policy(secureDefaultPolicy)
+ .transform().constant("Access granted - secure default");
+
+ // Route 2: maximum security - headers disabled
+ KeycloakSecurityPolicy maxSecurityPolicy = new
KeycloakSecurityPolicy();
+ maxSecurityPolicy.setServerUrl(serverUrl);
+ maxSecurityPolicy.setRealm(realm);
+ maxSecurityPolicy.setClientId(clientId);
+ maxSecurityPolicy.setClientSecret(clientSecret);
+ maxSecurityPolicy.setAllowTokenFromHeader(false);
+
+ from("direct:max-security")
+ .routeId("max-security")
+ .policy(maxSecurityPolicy)
+ .transform().constant("Access granted - max security");
+
+ // Route 3: legacy unsafe - prefer property disabled
+ KeycloakSecurityPolicy legacyPolicy = new KeycloakSecurityPolicy();
+ legacyPolicy.setServerUrl(serverUrl);
+ legacyPolicy.setRealm(realm);
+ legacyPolicy.setClientId(clientId);
+ legacyPolicy.setClientSecret(clientSecret);
+ legacyPolicy.setValidateTokenBinding(true);
+ legacyPolicy.setAllowTokenFromHeader(true);
+ legacyPolicy.setPreferPropertyOverHeader(false);
+
+ from("direct:legacy-unsafe")
+ .routeId("legacy-unsafe")
+ .policy(legacyPolicy)
+ .transform().constant("SHOULD NOT REACH HERE");
+
+ // Route 4: Admin-only route
+ KeycloakSecurityPolicy adminPolicy = new KeycloakSecurityPolicy();
+ adminPolicy.setServerUrl(serverUrl);
+ adminPolicy.setRealm(realm);
+ adminPolicy.setClientId(clientId);
+ adminPolicy.setClientSecret(clientSecret);
+ adminPolicy.setRequiredRoles(List.of("admin"));
+ adminPolicy.setPreferPropertyOverHeader(true);
+
+ from("direct:admin-only")
+ .routeId("admin-only")
+ .policy(adminPolicy)
+ .transform().constant("Admin access granted");
+
+ //
-------------------EvaluatePermissionRoute-----------------------------
+
+ // Route 1: Evaluate permission with clientId and clientSecret
+ from("direct:evaluatePermission")
+ .routeId("evaluate-permission")
+ .toD("keycloak:authz"
+ + "?serverUrl=" + serverUrl
+ + "&realm=" + realm
+ + "&clientId=${header.X-Authz-Client-Id}"
+ + "&clientSecret=${header.X-Authz-Client-Secret}"
+ + "&operation=evaluatePermission");
+
+ // Route 2: Evaluate permission with username/password
+ from("direct:evaluatePermissionUserPass")
+ .routeId("evaluate-permission-userpass")
+ .toD("keycloak:authz"
+ + "?serverUrl=" + serverUrl
+ + "&realm=" + realm
+ + "&clientId=${header.X-Authz-Client-Id}"
+ + "&clientSecret=${header.X-Authz-Client-Secret}"
+ + "&username=${header.X-Authz-Username}"
+ + "&password=${header.X-Authz-Password}"
+ + "&operation=evaluatePermission");
+
+ //
-------------------IntrospectionCacheRoute-----------------------------
+
+ // Route 1: introspection with ConcurrentHashMap cache (default)
+ KeycloakSecurityPolicy concurrentMapPolicy = new
KeycloakSecurityPolicy();
+ concurrentMapPolicy.setServerUrl(serverUrl);
+ concurrentMapPolicy.setRealm(realm);
+ concurrentMapPolicy.setClientId(clientId);
+ concurrentMapPolicy.setClientSecret(clientSecret);
+ concurrentMapPolicy.setUseTokenIntrospection(true);
+ concurrentMapPolicy.setIntrospectionCacheTtl(60);
+ concurrentMapPolicy.setIntrospectionCacheEnabled(true);
+
+ from("direct:introspection-concurrent-map")
+ .routeId("introspection-concurrent-map")
+ .policy(concurrentMapPolicy)
+ .transform().constant("Access granted - concurrent map cache");
+
+ // Route 2: introspection with no cache
+ KeycloakSecurityPolicy noCachePolicy = new KeycloakSecurityPolicy();
+ noCachePolicy.setServerUrl(serverUrl);
+ noCachePolicy.setRealm(realm);
+ noCachePolicy.setClientId(clientId);
+ noCachePolicy.setClientSecret(clientSecret);
+ noCachePolicy.setUseTokenIntrospection(true);
+ noCachePolicy.setIntrospectionCacheEnabled(false);
+
+ from("direct:introspection-no-cache")
+ .routeId("introspection-no-cache")
+ .policy(noCachePolicy)
+ .transform().constant("Access granted - no cache");
+ }
+}
diff --git
a/integration-tests/keycloak/src/main/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakSecurityPolicyResource.java
b/integration-tests/keycloak/src/main/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakSecurityPolicyResource.java
new file mode 100644
index 0000000000..57155b6c2f
--- /dev/null
+++
b/integration-tests/keycloak/src/main/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakSecurityPolicyResource.java
@@ -0,0 +1,337 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.camel.quarkus.component.keycloak.it;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import org.apache.camel.Exchange;
+import org.apache.camel.component.keycloak.security.KeycloakSecurityConstants;
+import org.apache.camel.component.keycloak.security.KeycloakTokenIntrospector;
+import org.apache.camel.component.keycloak.security.cache.TokenCache;
+import org.apache.camel.component.keycloak.security.cache.TokenCacheType;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+
+/**
+ * Keycloak REST resource for SecurityPolicy operations.
+ */
+@Path("/keycloak")
+@ApplicationScoped
+public class KeycloakSecurityPolicyResource extends KeycloakResourceSupport {
+
+ @ConfigProperty(name = "test.realm")
+ String testRealm;
+
+ @Path("/secure-policy/user-with-token-in-property")
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ public Response
securityPolicyWithTokenInProperty(@QueryParam("propertyToken") String
propertyToken) {
+ Exchange exchange = producerTemplate.send("direct:secure-default", e
-> {
+ e.setProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
propertyToken);
+ });
+
+ if (exchange.getException() != null) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(exchange.getException().getMessage())
+ .build();
+ }
+ return Response.ok(exchange.getMessage().getBody()).build();
+ }
+
+ @Path("/secure-policy/user-with-token-in-header")
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ public Response securityPolicyWithTokenInHeader(@QueryParam("headerToken")
String headerToken) {
+
+ Exchange exchange = producerTemplate.send("direct:secure-default", e
-> {
+ e.getIn().setHeader(KeycloakSecurityConstants.ACCESS_TOKEN_HEADER,
headerToken);
+ });
+
+ if (exchange.getException() != null) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(exchange.getException().getMessage())
+ .build();
+ }
+
+ return Response.ok(exchange.getMessage().getBody()).build();
+ }
+
+ @Path("/secure-policy/user-with-token-in-property-and-header")
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ public Response
securityPolicyWithPropertyPreferredOverHeader(@QueryParam("propertyToken")
String propertyToken,
+ @QueryParam("headerToken") String headerToken) {
+
+ Exchange exchange = producerTemplate.send("direct:secure-default", e
-> {
+ e.setProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
propertyToken);
+ e.getIn().setHeader(KeycloakSecurityConstants.ACCESS_TOKEN_HEADER,
headerToken);
+ });
+
+ if (exchange.getException() != null) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(exchange.getException().getMessage())
+ .build();
+ }
+
+ return Response.ok(exchange.getMessage().getBody()).build();
+ }
+
+ @Path("/secure-policy/max-security")
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ public Response
securityPolicyWithHeaderDisabled(@QueryParam("propertyToken") String
propertyToken,
+ @QueryParam("headerToken") String headerToken) {
+ Exchange exchange;
+ if (propertyToken != null) {
+ exchange = producerTemplate.send("direct:max-security", e -> {
+ e.setProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
propertyToken);
+ });
+ } else if (headerToken != null) {
+ exchange = producerTemplate.send("direct:max-security", e -> {
+
e.getIn().setHeader(KeycloakSecurityConstants.ACCESS_TOKEN_HEADER, headerToken);
+ });
+ } else {
+ exchange = producerTemplate.send("direct:max-security", e -> {
+ });
+ }
+
+ if (exchange.getException() != null) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(exchange.getException().getMessage())
+ .build();
+ }
+
+ return Response.ok(exchange.getMessage().getBody()).build();
+ }
+
+ @Path("/secure-policy/legacy-unsafe")
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ public Response
securityPolicyWithLegacyUnsafe(@QueryParam("propertyToken") String
propertyToken,
+ @QueryParam("headerToken") String headerToken) {
+
+ Exchange exchange = producerTemplate.send("direct:legacy-unsafe", e ->
{
+ e.setProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
propertyToken);
+ e.getIn().setHeader(KeycloakSecurityConstants.ACCESS_TOKEN_HEADER,
headerToken);
+ });
+
+ if (exchange.getException() != null) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(exchange.getException().getMessage())
+ .build();
+ }
+
+ return Response.ok(exchange.getMessage().getBody()).build();
+ }
+
+ @Path("/secure-policy/authorization-header-format")
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ public Response
securityPolicyWithAuthorizationHeader(@QueryParam("headerToken") String
headerToken) {
+
+ String result =
producerTemplate.requestBodyAndHeader("direct:secure-default", "test",
+ "Authorization", "Bearer " + headerToken, String.class);
+
+ return Response.ok(result).build();
+ }
+
+ @Path("/secure-policy/admin-only")
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ public Response securityPolicyWithAdminOnly(@QueryParam("propertyToken")
String propertyToken,
+ @QueryParam("headerToken") String headerToken) {
+
+ Exchange exchange = producerTemplate.send("direct:admin-only", e -> {
+ e.setProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
propertyToken);
+ e.getIn().setHeader(KeycloakSecurityConstants.ACCESS_TOKEN_HEADER,
headerToken);
+ });
+
+ if (exchange.getException() != null) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(exchange.getException().getMessage())
+ .build();
+ }
+
+ return Response.ok(exchange.getMessage().getBody()).build();
+ }
+
+ @Path("/secure-policy/introspection-cache-concurrent-map")
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ public Response policyWithConcurrentMapCache(
+ @QueryParam("propertyToken") String propertyToken) {
+
+ Exchange exchange =
producerTemplate.send("direct:introspection-concurrent-map", e -> {
+ e.setProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
propertyToken);
+ });
+
+ if (exchange.getException() != null) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(exchange.getException().getMessage())
+ .build();
+ }
+ return Response.ok(exchange.getMessage().getBody()).build();
+ }
+
+ @Path("/secure-policy/introspection-no-cache")
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ public Response policyWithNoCache(
+ @QueryParam("propertyToken") String propertyToken) {
+
+ Exchange exchange =
producerTemplate.send("direct:introspection-no-cache", e -> {
+ e.setProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
propertyToken);
+ });
+
+ if (exchange.getException() != null) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(exchange.getException().getMessage())
+ .build();
+ }
+ return Response.ok(exchange.getMessage().getBody()).build();
+ }
+
+ @Path("/introspection-cache/introspector/concurrent-map")
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response introspectorWithConcurrentMap(
+ @QueryParam("accessToken") String accessToken,
+ @QueryParam("clientId") String clientId,
+ @QueryParam("clientSecret") String clientSecret) {
+
+ KeycloakTokenIntrospector introspector = buildIntrospector(clientId,
clientSecret,
+ TokenCacheType.CONCURRENT_MAP,
+ 60, 0, false);
+
+ try {
+ return buildIntrospectionResponse(introspector, accessToken);
+ } finally {
+ introspector.close();
+ }
+ }
+
+ @Path("/introspection-cache/introspector/caffeine")
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response introspectorWithCaffeine(
+ @QueryParam("accessToken") String accessToken,
+ @QueryParam("clientId") String clientId,
+ @QueryParam("clientSecret") String clientSecret) {
+
+ KeycloakTokenIntrospector introspector = buildIntrospector(clientId,
clientSecret,
+ TokenCacheType.CAFFEINE,
+ 60, 100, false);
+
+ try {
+ return buildIntrospectionResponse(introspector, accessToken);
+ } finally {
+ introspector.close();
+ }
+ }
+
+ @Path("/introspection-cache/introspector/none")
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response introspectorWithNoCache(
+ @QueryParam("accessToken") String accessToken,
+ @QueryParam("clientId") String clientId,
+ @QueryParam("clientSecret") String clientSecret) {
+
+ KeycloakTokenIntrospector introspector = buildIntrospector(clientId,
clientSecret,
+ TokenCacheType.NONE,
+ 60, 0, false);
+
+ try {
+ return buildIntrospectionResponse(introspector, accessToken);
+ } finally {
+ introspector.close();
+ }
+ }
+
+ @Path("/introspection-cache/introspector/caffeine-stats")
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response introspectorCaffeineStats(
+ @QueryParam("accessToken") String accessToken,
+ @QueryParam("clientId") String clientId,
+ @QueryParam("clientSecret") String clientSecret) {
+
+ KeycloakTokenIntrospector introspector = buildIntrospector(clientId,
clientSecret,
+ TokenCacheType.CAFFEINE,
+ 60, 100, true);
+
+ try {
+ introspector.introspect(accessToken);
+ introspector.introspect(accessToken);
+
+ TokenCache.CacheStats stats = introspector.getCacheStats();
+
+ Map<String, Object> result = new HashMap<>();
+ result.put("hitCount", stats.getHitCount());
+ result.put("missCount", stats.getMissCount());
+ result.put("evictionCount", stats.getEvictionCount());
+ result.put("hitRate", stats.getHitRate());
+ result.put("cacheSize", introspector.getCacheSize());
+
+ return Response.ok(result).build();
+ } catch (Exception e) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(e.getMessage())
+ .build();
+ } finally {
+ introspector.close();
+ }
+ }
+
+ private KeycloakTokenIntrospector buildIntrospector(String clientId,
String clientSecret, TokenCacheType cacheType,
+ long ttl, long maxSize, boolean recordStats) {
+ return new KeycloakTokenIntrospector(keycloakUrl, testRealm, clientId,
clientSecret, cacheType, ttl, maxSize,
+ recordStats);
+ }
+
+ private Response buildIntrospectionResponse(
+ KeycloakTokenIntrospector introspector, String accessToken) {
+ try {
+ KeycloakTokenIntrospector.IntrospectionResult result =
introspector.introspect(accessToken);
+
+ Map<String, Object> body = new HashMap<>();
+ body.put("active", result.isActive());
+ body.put("subject", result.getSubject());
+ body.put("cacheSize", introspector.getCacheSize());
+
+ TokenCache.CacheStats stats = introspector.getCacheStats();
+ if (stats != null) {
+ body.put("hitCount", stats.getHitCount());
+ body.put("missCount", stats.getMissCount());
+ body.put("hitRate", stats.getHitRate());
+ }
+
+ return Response.ok(body).build();
+ } catch (Exception e) {
+ return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
+ .entity(e.getMessage())
+ .build();
+ }
+ }
+}
diff --git
a/integration-tests/keycloak/src/main/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakUserResource.java
b/integration-tests/keycloak/src/main/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakUserResource.java
index 62752ad95b..e244f8b690 100644
---
a/integration-tests/keycloak/src/main/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakUserResource.java
+++
b/integration-tests/keycloak/src/main/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakUserResource.java
@@ -24,6 +24,7 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
+import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
@@ -102,6 +103,28 @@ public class KeycloakUserResource extends
KeycloakResourceSupport {
return Response.ok("User created successfully").build();
}
+ @Path("/user/{realmName}")
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response bulkCreateUsers(
+ @PathParam("realmName") String realmName,
+ List<UserRepresentation> users) {
+
+ Map<String, Object> headers = new HashMap<>();
+ headers.put(KeycloakConstants.REALM_NAME, realmName);
+
+ headers.put(KeycloakConstants.USERS, users);
+
+ Object result = producerTemplate.requestBodyAndHeaders(
+ getKeycloakEndpoint() + "&operation=bulkCreateUsers",
+ null,
+ headers,
+ Map.class);
+
+ return Response.ok(result).build();
+ }
+
@Path("/user/{realmName}/{username}")
@GET
@Produces(MediaType.APPLICATION_JSON)
@@ -162,6 +185,26 @@ public class KeycloakUserResource extends
KeycloakResourceSupport {
return Response.ok("User deleted successfully").build();
}
+ @Path("/user/{realmName}")
+ @DELETE
+ @Produces(MediaType.APPLICATION_JSON)
+ @Consumes(MediaType.APPLICATION_JSON)
+ public Response bulkDeleteUsers(
+ @PathParam("realmName") String realmName,
+ List<String> userNameList) {
+
+ Map<String, Object> headers = new HashMap<>();
+ headers.put(KeycloakConstants.REALM_NAME, realmName);
+ headers.put(KeycloakConstants.USERNAMES, userNameList);
+
+ Object result = producerTemplate.requestBodyAndHeaders(
+ getKeycloakEndpoint() + "&operation=bulkDeleteUsers",
+ null,
+ headers);
+
+ return Response.ok(result).build();
+ }
+
@Path("/user/{realmName}/{username}")
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@@ -187,6 +230,31 @@ public class KeycloakUserResource extends
KeycloakResourceSupport {
return Response.ok("User updated successfully").build();
}
+ @Path("/user/{realmName}")
+ @PUT
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response bulkUpdateUsers(
+ @PathParam("realmName") String realmName,
+ @HeaderParam("continueOnError") Boolean continueOnError,
+ List<UserRepresentation> users) {
+
+ Map<String, Object> headers = new HashMap<>();
+ headers.put(KeycloakConstants.REALM_NAME, realmName);
+ headers.put(KeycloakConstants.USERS, users);
+ if (continueOnError != null) {
+ headers.put(KeycloakConstants.CONTINUE_ON_ERROR, continueOnError);
+ }
+
+ Object result = producerTemplate.requestBodyAndHeaders(
+ getKeycloakEndpoint() + "&operation=bulkUpdateUsers",
+ null,
+ headers,
+ Map.class);
+
+ return Response.ok(result).build();
+ }
+
@Path("/user/{realmName}/search")
@GET
@Produces(MediaType.APPLICATION_JSON)
@@ -234,6 +302,54 @@ public class KeycloakUserResource extends
KeycloakResourceSupport {
return Response.ok(result).build();
}
+ @Path("/user-role/{realmName}/user/{username}")
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response assignRolesToUser(
+ @PathParam("realmName") String realmName,
+ @PathParam("username") String username,
+ List<String> roleNameList) {
+
+ String userId = getUserIdByUsername(realmName, username);
+
+ Map<String, Object> headers = new HashMap<>();
+ headers.put(KeycloakConstants.REALM_NAME, realmName);
+ headers.put(KeycloakConstants.USER_ID, userId);
+ headers.put(KeycloakConstants.ROLE_NAMES, roleNameList);
+
+ Object result = producerTemplate.requestBodyAndHeaders(
+ getKeycloakEndpoint() + "&operation=bulkAssignRolesToUser",
+ null,
+ headers,
+ Map.class);
+
+ return Response.ok(result).build();
+ }
+
+ @Path("/user-role/{realmName}/role/{roleName}")
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ public Response assignRoleToUsers(
+ @PathParam("realmName") String realmName,
+ @PathParam("roleName") String roleName,
+ List<String> userNameList) {
+
+ Map<String, Object> headers = new HashMap<>();
+ headers.put(KeycloakConstants.REALM_NAME, realmName);
+ headers.put(KeycloakConstants.ROLE_NAME, roleName);
+ headers.put(KeycloakConstants.USERNAMES, userNameList);
+
+ Object result = producerTemplate.requestBodyAndHeaders(
+ getKeycloakEndpoint() + "&operation=bulkAssignRoleToUsers",
+ null,
+ headers,
+ Map.class);
+
+ return Response.ok(result).build();
+ }
+
@Path("/user-role/{realmName}/{username}/{roleName}")
@DELETE
@Produces(MediaType.TEXT_PLAIN)
diff --git
a/integration-tests/keycloak/src/main/resources/application.properties
b/integration-tests/keycloak/src/main/resources/application.properties
index 09d7567767..026598a0a7 100644
--- a/integration-tests/keycloak/src/main/resources/application.properties
+++ b/integration-tests/keycloak/src/main/resources/application.properties
@@ -23,3 +23,5 @@ greenmail.container.image=${greenmail.container.image}
# and keycloak-core contain overlapping packages
# This is due the presence of the quarkus-test-keycloak-server dependency.
quarkus.arc.ignored-split-packages=org.keycloak.*
+
+camel.component.caffeine-cache.stats-enabled = true
\ No newline at end of file
diff --git
a/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakEvaluatePermissionTest.java
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakEvaluatePermissionTest.java
new file mode 100644
index 0000000000..a24e15e238
--- /dev/null
+++
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakEvaluatePermissionTest.java
@@ -0,0 +1,331 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.camel.quarkus.component.keycloak.it;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import io.quarkus.test.common.QuarkusTestResource;
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.http.ContentType;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.keycloak.representations.idm.ClientRepresentation;
+import org.keycloak.representations.idm.UserRepresentation;
+import org.keycloak.representations.idm.authorization.Logic;
+import org.keycloak.representations.idm.authorization.PolicyRepresentation;
+import
org.keycloak.representations.idm.authorization.ResourcePermissionRepresentation;
+import org.keycloak.representations.idm.authorization.ResourceRepresentation;
+import org.keycloak.representations.idm.authorization.ScopeRepresentation;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.CoreMatchers.anyOf;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+@QuarkusTest
+@QuarkusTestResource(KeycloakTestResource.class)
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class KeycloakEvaluatePermissionTest extends KeycloakTestBase {
+
+ private static String userToken;
+ private static final String RESOURCE_DOCUMENTS = "documents";
+ private static final String SCOPE_READ = "read";
+
+ @Test
+ @Order(1)
+ public void testSetup() {
+ KeycloakRealmLifecycle.createRealmWithSmtp(config("test.realm"));
+
+ // 1. Confidential client with Authorization Services enabled
+ ClientRepresentation authzClient = new ClientRepresentation();
+ authzClient.setClientId(TEST_CLIENT_ID);
+ authzClient.setSecret(TEST_CLIENT_SECRET);
+ authzClient.setPublicClient(false);
+ authzClient.setDirectAccessGrantsEnabled(true);
+ authzClient.setServiceAccountsEnabled(true);
+ authzClient.setAuthorizationServicesEnabled(true);
+ authzClient.setStandardFlowEnabled(false);
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(authzClient)
+ .post("/keycloak/client/{realmName}/pojo",
config("test.realm"))
+ .then()
+ .statusCode(anyOf(is(200), is(201)));
+
+ // 2. Protected resource with a read scope
+ ScopeRepresentation readScope = new ScopeRepresentation();
+ readScope.setName(SCOPE_READ);
+
+ ResourceRepresentation documents = new ResourceRepresentation();
+ documents.setName(RESOURCE_DOCUMENTS);
+ documents.setUris(Set.of("/documents/*"));
+ documents.setScopes(Set.of(readScope));
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(documents)
+ .post("/keycloak/resource/{realmName}/{clientId}/pojo",
+ config("test.realm"), TEST_CLIENT_ID)
+ .then()
+ .statusCode(anyOf(is(200), is(201)));
+
+ // 3. Test user
+ UserRepresentation user = new UserRepresentation();
+ user.setUsername(TEST_USER_NAME);
+ user.setEmail(TEST_USER_NAME + "@test.com");
+ user.setFirstName("Test");
+ user.setLastName("User");
+ user.setEnabled(true);
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(List.of(user))
+ .post("/keycloak/user/{realmName}", config("test.realm"))
+ .then()
+ .statusCode(200);
+
+ given()
+ .queryParam("password", TEST_USER_PASSWORD)
+ .queryParam("temporary", false)
+ .post("/keycloak/user/{realmName}/{username}/reset-password",
+ config("test.realm"), TEST_USER_NAME)
+ .then()
+ .statusCode(200);
+
+ String userId = given()
+ .get("/keycloak/user/{realmName}/{username}",
+ config("test.realm"), TEST_USER_NAME)
+ .then()
+ .statusCode(200)
+ .extract().jsonPath().getString("id");
+
+ assertNotNull(userId, "userId must not be null after creation");
+
+ // 4. User policy
+ PolicyRepresentation userPolicy = new PolicyRepresentation();
+ userPolicy.setType("user");
+ userPolicy.setName("user-policy-" +
UUID.randomUUID().toString().substring(0, 6));
+ userPolicy.setLogic(Logic.POSITIVE);
+ userPolicy.setConfig(Map.of("users", "[\"" + userId + "\"]"));
+
+ String policyLocation = given()
+ .contentType(ContentType.JSON)
+ .body(userPolicy)
+ .post("/keycloak/resource-policy/{realmName}/{clientId}/pojo",
+ config("test.realm"), TEST_CLIENT_ID)
+ .then()
+ .statusCode(anyOf(is(200), is(201)))
+ .extract().header("Location");
+
+ String policyId = policyLocation != null
+ ? policyLocation.substring(policyLocation.lastIndexOf('/') + 1)
+ : fetchPolicyId(TEST_CLIENT_ID, userPolicy.getName());
+
+ // 5. Resource permission
+ ResourcePermissionRepresentation permission = new
ResourcePermissionRepresentation();
+ permission.setName("docs-perm-" +
UUID.randomUUID().toString().substring(0, 6));
+ permission.setResources(Set.of(RESOURCE_DOCUMENTS));
+ permission.setPolicies(Set.of(policyId));
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(permission)
+
.post("/keycloak/resource-permission/{realmName}/{clientId}/pojo",
+ config("test.realm"), TEST_CLIENT_ID)
+ .then()
+ .statusCode(anyOf(is(200), is(201)));
+
+ // 6. Obtain access token
+ userToken = getAccessToken(TEST_USER_NAME, TEST_USER_PASSWORD,
TEST_CLIENT_ID, TEST_CLIENT_SECRET);
+ assertNotNull(userToken, "userToken must be non-null after setup");
+ }
+
+ @Test
+ @Order(2)
+ public void testPermissionsOnly_responseContainsRequiredFields() {
+ given()
+ .queryParam("clientId", TEST_CLIENT_ID)
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+ .queryParam("accessToken", userToken)
+ .get("/keycloak/evaluate-permission/permissions-only")
+ .then()
+ .statusCode(200)
+ .body("permissions", notNullValue())
+ .body("permissionCount", notNullValue())
+ .body("granted", notNullValue());
+ }
+
+ @Test
+ @Order(3)
+ public void testPermissionsOnly_userGrantedAccessToDocumentsRead() {
+ given()
+ .queryParam("clientId", TEST_CLIENT_ID)
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+ .queryParam("accessToken", userToken)
+ .queryParam("resourceNames", RESOURCE_DOCUMENTS)
+ .queryParam("scopes", SCOPE_READ)
+ .get("/keycloak/evaluate-permission/permissions-only")
+ .then()
+ .statusCode(200)
+ .body("granted", is(true))
+ .body("permissionCount", greaterThan(0));
+ }
+
+ @Test
+ @Order(4)
+ public void testRptMode_responseContainsAllExpectedFields() {
+ given()
+ .queryParam("clientId", TEST_CLIENT_ID)
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+ .queryParam("accessToken", userToken)
+ .queryParam("resourceNames", RESOURCE_DOCUMENTS)
+ .get("/keycloak/evaluate-permission/rpt")
+ .then()
+ .statusCode(200)
+ .body("token", notNullValue())
+ .body("tokenType", is("Bearer"))
+ .body("expiresIn", notNullValue())
+ .body("refreshToken", notNullValue())
+ .body("refreshExpiresIn", notNullValue())
+ .body("upgraded", notNullValue());
+ }
+
+ @Test
+ @Order(5)
+ public void testPermissionsOnly_resourceNamesWithWhitespace() {
+ given()
+ .queryParam("clientId", TEST_CLIENT_ID)
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+ .queryParam("accessToken", userToken)
+ .queryParam("resourceNames", " " + RESOURCE_DOCUMENTS + " ,
unknown-resource ")
+ .get("/keycloak/evaluate-permission/permissions-only")
+ .then()
+ .statusCode(200)
+ .body("granted", is(true));
+ }
+
+ @Test
+ @Order(6)
+ public void testPermissionsOnly_scopeOnlyNoResource() {
+ given()
+ .queryParam("clientId", TEST_CLIENT_ID)
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+ .queryParam("accessToken", userToken)
+ .queryParam("scopes", SCOPE_READ)
+ .get("/keycloak/evaluate-permission/permissions-only")
+ .then()
+ .statusCode(200)
+ .body("granted", is(true));
+ }
+
+ @Test
+ @Order(7)
+ public void testPermissionsOnly_withAudienceHeader() {
+ given()
+ .queryParam("clientId", TEST_CLIENT_ID)
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+ .queryParam("accessToken", userToken)
+ .queryParam("resourceNames", RESOURCE_DOCUMENTS)
+ .queryParam("audience", TEST_CLIENT_ID)
+ .get("/keycloak/evaluate-permission/audience")
+ .then()
+ .statusCode(200)
+ .body("granted", is(true));
+ }
+
+ @Test
+ @Order(8)
+ public void testPermissionsOnly_withSubjectToken() {
+ given()
+ .queryParam("clientId", TEST_CLIENT_ID)
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+ .queryParam("subjectToken", userToken)
+ .queryParam("resourceNames", RESOURCE_DOCUMENTS)
+ .get("/keycloak/evaluate-permission/subject-token")
+ .then()
+ .statusCode(200)
+ .body("granted", is(true));
+ }
+
+ @Test
+ @Order(9)
+ public void testEvaluatePermission_usernamePasswordAuth_granted() {
+ given()
+ .queryParam("clientId", TEST_CLIENT_ID)
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+ .queryParam("username", TEST_USER_NAME)
+ .queryParam("password", TEST_USER_PASSWORD)
+ .queryParam("resourceNames", RESOURCE_DOCUMENTS)
+ .get("/keycloak/evaluate-permission/username-password")
+ .then()
+ .statusCode(200)
+ .body("granted", is(true));
+ }
+
+ @Test
+ @Order(10)
+ public void
testEvaluatePermission_missingClientSecret_returns500WithValidationMessage() {
+ given()
+ .queryParam("clientId", TEST_CLIENT_ID)
+ .queryParam("clientSecret", "")
+ .queryParam("accessToken", userToken)
+ .get("/keycloak/evaluate-permission/permissions-only")
+ .then()
+ .statusCode(500)
+ .body(containsString("Client secret must be specified"));
+ }
+
+ @Test
+ @Order(11)
+ public void testEvaluatePermission_invalidToken_returns500WithAuthError() {
+ given()
+ .queryParam("clientId", TEST_CLIENT_ID)
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+ .queryParam("accessToken", "invalid.token")
+ .get("/keycloak/evaluate-permission/permissions-only")
+ .then()
+ .statusCode(500)
+ .body(containsString("401"));
+ }
+
+ @Test
+ @Order(100)
+ public void testCleanup_DeleteRealm() {
+ KeycloakRealmLifecycle.deleteRealm(config("test.realm"));
+ }
+
+ private String fetchPolicyId(String clientId, String policyName) {
+ return given()
+ .get("/keycloak/resource-policy/{realmName}/{clientId}",
+ config("test.realm"), clientId)
+ .then()
+ .statusCode(200)
+ .extract().jsonPath()
+ .param("name", policyName)
+ .getString("find { it.name == name }.id");
+ }
+}
diff --git
a/extensions/keycloak/deployment/src/main/java/org/apache/camel/quarkus/component/keycloak/deployment/KeycloakProcessor.java
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakEvaluatePermissionTestIT.java
similarity index 50%
copy from
extensions/keycloak/deployment/src/main/java/org/apache/camel/quarkus/component/keycloak/deployment/KeycloakProcessor.java
copy to
integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakEvaluatePermissionTestIT.java
index 85530325c0..725a88a7fb 100644
---
a/extensions/keycloak/deployment/src/main/java/org/apache/camel/quarkus/component/keycloak/deployment/KeycloakProcessor.java
+++
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakEvaluatePermissionTestIT.java
@@ -14,26 +14,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.camel.quarkus.component.keycloak.deployment;
-import io.quarkus.deployment.annotations.BuildProducer;
-import io.quarkus.deployment.annotations.BuildStep;
-import io.quarkus.deployment.builditem.FeatureBuildItem;
-import
io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem;
-import org.keycloak.common.util.BouncyIntegration;
+package org.apache.camel.quarkus.component.keycloak.it;
-class KeycloakProcessor {
-
- private static final String FEATURE = "camel-keycloak";
-
- @BuildStep
- FeatureBuildItem feature() {
- return new FeatureBuildItem(FEATURE);
- }
-
- @BuildStep
- void
runtimeInitializedClasses(BuildProducer<RuntimeInitializedClassBuildItem>
runtimeInitializedClass) {
- runtimeInitializedClass.produce(new
RuntimeInitializedClassBuildItem(BouncyIntegration.class.getName()));
- }
+import io.quarkus.test.junit.QuarkusIntegrationTest;
+@QuarkusIntegrationTest
+public class KeycloakEvaluatePermissionTestIT extends
KeycloakEvaluatePermissionTest {
}
diff --git
a/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakRoleTest.java
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakRoleTest.java
index 7a0983b15b..596f0a56b4 100644
---
a/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakRoleTest.java
+++
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakRoleTest.java
@@ -21,8 +21,12 @@ import java.util.List;
import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
-import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
import org.keycloak.representations.idm.RoleRepresentation;
+import org.keycloak.representations.idm.UserRepresentation;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;
@@ -196,6 +200,87 @@ class KeycloakRoleTest extends KeycloakTestBase {
.body(is("Role removed from user successfully"));
}
+ @Test
+ @Order(9)
+ public void testAssignRolesToUser() {
+ List<String> existingRoleNames = given()
+ .when()
+ .get("/keycloak/role/{realmName}", TEST_REALM_NAME)
+ .then()
+ .statusCode(200)
+ .contentType(ContentType.JSON)
+ .extract()
+ .body()
+ .jsonPath()
+ .getList(".",
RoleRepresentation.class).stream().map(RoleRepresentation::getName).toList();
+
+ given()
+ .when()
+ .contentType(ContentType.JSON)
+ .body(existingRoleNames)
+ .post("/keycloak/user-role/{realmName}/user/{username}",
+ TEST_REALM_NAME, TEST_USER_NAME)
+ .then()
+ .statusCode(200)
+ .body("total", is(existingRoleNames.size()))
+ .body("success", is(existingRoleNames.size()))
+ .body("assigned", is(existingRoleNames.size()))
+ .body("results.size()", is(existingRoleNames.size()));
+ }
+
+ @Test
+ @Order(10)
+ public void testAssignRoleToUsers() {
+ // get an role
+ RoleRepresentation role = given()
+ .when()
+ .get("/keycloak/role/{realmName}/{roleName}", TEST_REALM_NAME,
TEST_ROLE_NAME)
+ .then()
+ .statusCode(200)
+ .contentType(ContentType.JSON)
+ .extract()
+ .as(RoleRepresentation.class);
+
+ // Create additional test user for user-role operations
+ String additionalTestUserName = TEST_USER_NAME + "-additionalTestUser";
+ given()
+ .queryParam("email", additionalTestUserName + "@test.com")
+ .queryParam("firstName", "AdditionalTest")
+ .queryParam("lastName", "AdditionalUser")
+ .when()
+ .post("/keycloak/user/{realmName}/{username}",
TEST_REALM_NAME, additionalTestUserName)
+ .then()
+ .statusCode(201);
+
+ // get all existing users
+ List<String> allUserNames = given()
+ .when()
+ .get("/keycloak/user/{realmName}", TEST_REALM_NAME)
+ .then()
+ .statusCode(200)
+ .contentType(ContentType.JSON)
+ .extract()
+ .body()
+ .jsonPath()
+ .getList(".",
UserRepresentation.class).stream().map(UserRepresentation::getUsername).toList();
+
+ assertThat(allUserNames.size(), is(2));
+
+ // assign one role to all users
+ given()
+ .when()
+ .contentType(ContentType.JSON)
+ .body(allUserNames)
+ .post("/keycloak/user-role/{realmName}/role/{roleName}",
+ TEST_REALM_NAME, role.getName())
+ .then()
+ .statusCode(200)
+ .body("total", is(allUserNames.size()))
+ .body("success", is(allUserNames.size()))
+ .body("roleName", is(role.getName()))
+ .body("results.size()", is(allUserNames.size()));
+ }
+
@Test
@Order(100)
public void testCleanupRoles() {
diff --git
a/extensions/keycloak/deployment/src/main/java/org/apache/camel/quarkus/component/keycloak/deployment/KeycloakProcessor.java
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakSecurityPolicyIT.java
similarity index 50%
copy from
extensions/keycloak/deployment/src/main/java/org/apache/camel/quarkus/component/keycloak/deployment/KeycloakProcessor.java
copy to
integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakSecurityPolicyIT.java
index 85530325c0..77b2a2a068 100644
---
a/extensions/keycloak/deployment/src/main/java/org/apache/camel/quarkus/component/keycloak/deployment/KeycloakProcessor.java
+++
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakSecurityPolicyIT.java
@@ -14,26 +14,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.camel.quarkus.component.keycloak.deployment;
+package org.apache.camel.quarkus.component.keycloak.it;
-import io.quarkus.deployment.annotations.BuildProducer;
-import io.quarkus.deployment.annotations.BuildStep;
-import io.quarkus.deployment.builditem.FeatureBuildItem;
-import
io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem;
-import org.keycloak.common.util.BouncyIntegration;
-
-class KeycloakProcessor {
-
- private static final String FEATURE = "camel-keycloak";
-
- @BuildStep
- FeatureBuildItem feature() {
- return new FeatureBuildItem(FEATURE);
- }
-
- @BuildStep
- void
runtimeInitializedClasses(BuildProducer<RuntimeInitializedClassBuildItem>
runtimeInitializedClass) {
- runtimeInitializedClass.produce(new
RuntimeInitializedClassBuildItem(BouncyIntegration.class.getName()));
- }
+import io.quarkus.test.junit.QuarkusIntegrationTest;
+@QuarkusIntegrationTest
+public class KeycloakSecurityPolicyIT extends KeycloakSecurityPolicyTest {
}
diff --git
a/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakSecurityPolicyTest.java
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakSecurityPolicyTest.java
new file mode 100644
index 0000000000..ad36a31bf7
--- /dev/null
+++
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakSecurityPolicyTest.java
@@ -0,0 +1,432 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.camel.quarkus.component.keycloak.it;
+
+import java.util.List;
+
+import io.quarkus.test.common.QuarkusTestResource;
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.http.ContentType;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.keycloak.representations.idm.ClientRepresentation;
+import org.keycloak.representations.idm.UserRepresentation;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.notNullValue;
+
+@QuarkusTest
+@QuarkusTestResource(KeycloakTestResource.class)
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class KeycloakSecurityPolicyTest extends KeycloakSecurityPolicyTestBase
{
+
+ private static String adminToken;
+ private static String normalUserToken;
+ private static String attackerToken;
+
+ @Test
+ @Order(1)
+ public void testSetup() {
+ createRealm();
+ createClient();
+
+ createRole(ADMIN_ROLE);
+ createRole(USER_ROLE);
+
+ createUser(ADMIN_USER);
+ createUser(NORMAL_USER);
+ createUser(ATTACKER_USER);
+
+ resetPassword(ADMIN_USER, ADMIN_PASSWORD);
+ resetPassword(NORMAL_USER, NORMAL_PASSWORD);
+ resetPassword(ATTACKER_USER, ATTACKER_PASSWORD);
+
+ assignRole(ADMIN_USER, ADMIN_ROLE);
+ assignRole(NORMAL_USER, USER_ROLE);
+ assignRole(ATTACKER_USER, USER_ROLE);
+
+ adminToken = getAccessToken(ADMIN_USER, ADMIN_PASSWORD,
config("test.client.id"), TEST_CLIENT_SECRET);
+ normalUserToken = getAccessToken(NORMAL_USER, NORMAL_PASSWORD,
config("test.client.id"), TEST_CLIENT_SECRET);
+ attackerToken = getAccessToken(ATTACKER_USER, ATTACKER_PASSWORD,
config("test.client.id"), TEST_CLIENT_SECRET);
+ }
+
+ @Test
+ @Order(2)
+ public void testPropertyTokenWorks() {
+ given()
+ .when()
+ .queryParam("propertyToken", normalUserToken)
+ .get("/keycloak/secure-policy/user-with-token-in-property")
+ .then()
+ .statusCode(200)
+ .body(is("Access granted - secure default"));
+ }
+
+ @Test
+ @Order(3)
+ public void testHeaderTokenWorks() {
+ given()
+ .when()
+ .queryParam("headerToken", normalUserToken)
+ .get("/keycloak/secure-policy/user-with-token-in-header")
+ .then()
+ .statusCode(200)
+ .body(is("Access granted - secure default"));
+ }
+
+ @Test
+ @Order(4)
+ public void testPropertyPreferredOverHeaderTokenWorks() {
+ given()
+ .when()
+ .queryParam("propertyToken", normalUserToken)
+ .queryParam("headerToken", attackerToken)
+
.get("/keycloak/secure-policy/user-with-token-in-property-and-header")
+ .then()
+ .statusCode(200)
+ .body(is("Access granted - secure default"));
+ }
+
+ @Test
+ @Order(5)
+ public void testInvalidHeaderIgnoredWhenPropertyValid() {
+ given()
+ .when()
+ .queryParam("propertyToken", normalUserToken)
+ .queryParam("headerToken", "invalid.token")
+
.get("/keycloak/secure-policy/user-with-token-in-property-and-header")
+ .then()
+ .statusCode(200)
+ .body(is("Access granted - secure default"));
+ }
+
+ @Test
+ @Order(6)
+ public void testHeaderRejectedWhenHeadersDisabled() {
+ given()
+ .when()
+ .queryParam("headerToken", normalUserToken)
+ .get("/keycloak/secure-policy/max-security")
+ .then()
+ .statusCode(500)
+ .body(containsString("Access token not found in exchange"));
+ }
+
+ @Test
+ @Order(7)
+ public void testPropertyWorksWhenHeadersDisabled() {
+ given()
+ .when()
+ .queryParam("propertyToken", normalUserToken)
+ .get("/keycloak/secure-policy/max-security")
+ .then()
+ .statusCode(200)
+ .body(is("Access granted - max security"));
+ }
+
+ @Test
+ @Order(8)
+ public void testPropertyTokenUsedNotHeader() {
+ given()
+ .when()
+ .queryParam("propertyToken", normalUserToken)
+ .queryParam("headerToken", adminToken)
+
.get("/keycloak/secure-policy/user-with-token-in-property-and-header")
+ .then()
+ .statusCode(200)
+ .body(is("Access granted - secure default"));
+ }
+
+ @Test
+ @Order(9)
+ public void testAttackScenario_SessionHijacking() {
+ given()
+ .when()
+ .queryParam("propertyToken", normalUserToken)
+ .queryParam("headerToken", attackerToken)
+
.get("/keycloak/secure-policy/user-with-token-in-property-and-header")
+ .then()
+ .statusCode(200)
+ .body(is("Access granted - secure default"));
+ }
+
+ @Test
+ @Order(10)
+ public void testAttackScenario_LegacyUnsafe() {
+ given()
+ .when()
+ .queryParam("propertyToken", normalUserToken)
+ .queryParam("headerToken", attackerToken)
+ .get("/keycloak/secure-policy/legacy-unsafe")
+ .then()
+ .statusCode(500)
+ .body(containsString("Token mismatch detected"));
+ }
+
+ @Test
+ @Order(11)
+ public void testAuthorizationHeaderFormat() {
+ given()
+ .when()
+ .queryParam("headerToken", normalUserToken)
+ .get("/keycloak/secure-policy/authorization-header-format")
+ .then()
+ .statusCode(200)
+ .body(is("Access granted - secure default"));
+ }
+
+ @Test
+ @Order(12)
+ public void testNoTokenRejected() {
+ given()
+ .when()
+ .get("/keycloak/secure-policy/max-security")
+ .then()
+ .statusCode(500)
+ .body(containsString("Access token not found in exchange"));
+ }
+
+ @Test
+ @Order(13)
+ public void testAdminOnly() {
+ given()
+ .when()
+ .queryParam("propertyToken", adminToken)
+ .queryParam("headerToken", normalUserToken)
+ .get("/keycloak/secure-policy/admin-only")
+ .then()
+ .statusCode(200)
+ .body(is("Admin access granted"));
+ }
+
+ @Test
+ @Order(14)
+ public void testIntrospectionEnabledWithDefaultCacheConcurrentMap() {
+ given()
+ .when()
+ .queryParam("propertyToken", adminToken)
+ .queryParam("clientId", config("test.client.id"))
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+
.get("/keycloak/secure-policy/introspection-cache-concurrent-map")
+ .then()
+ .statusCode(200)
+ .body(is("Access granted - concurrent map cache"));
+ }
+
+ @Test
+ @Order(15)
+ public void testIntrospectionEnabledWithNoCache() {
+ given()
+ .when()
+ .queryParam("propertyToken", adminToken)
+ .queryParam("clientId", config("test.client.id"))
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+ .get("/keycloak/secure-policy/introspection-no-cache")
+ .then()
+ .statusCode(200)
+ .body(is("Access granted - no cache"));
+ }
+
+ @Test
+ @Order(16)
+ public void testIntrospector_concurrentMapCache_tokenIsActive() {
+ given()
+ .queryParam("accessToken", normalUserToken)
+ .queryParam("clientId", config("test.client.id"))
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+
.get("/keycloak/introspection-cache/introspector/concurrent-map")
+ .then()
+ .statusCode(200)
+ .body("active", is(true))
+ .body("subject", notNullValue())
+ .body("cacheSize", greaterThan(0));
+ }
+
+ @Test
+ @Order(17)
+ public void
testIntrospector_concurrentMapCache_invalidToken_returnsInactive() {
+ given()
+ .queryParam("accessToken", "invalid.token")
+ .queryParam("clientId", config("test.client.id"))
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+
.get("/keycloak/introspection-cache/introspector/concurrent-map")
+ .then()
+ .statusCode(200)
+ .body("active", is(false));
+ }
+
+ @Test
+ @Order(18)
+ public void testIntrospector_caffeineCache_tokenIsActive() {
+ given()
+ .queryParam("accessToken", normalUserToken)
+ .queryParam("clientId", config("test.client.id"))
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+ .get("/keycloak/introspection-cache/introspector/caffeine")
+ .then()
+ .statusCode(200)
+ .body("active", is(true))
+ .body("subject", notNullValue())
+ .body("cacheSize", greaterThan(0));
+ }
+
+ @Test
+ @Order(19)
+ public void testIntrospector_caffeineCache_invalidToken_returnsInactive() {
+ given()
+ .queryParam("accessToken", "invalid.token")
+ .queryParam("clientId", config("test.client.id"))
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+ .get("/keycloak/introspection-cache/introspector/caffeine")
+ .then()
+ .statusCode(200)
+ .body("active", is(false));
+ }
+
+ @Test
+ @Order(20)
+ public void testIntrospector_noCache_tokenIsActive() {
+ given()
+ .queryParam("accessToken", normalUserToken)
+ .queryParam("clientId", config("test.client.id"))
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+ .get("/keycloak/introspection-cache/introspector/none")
+ .then()
+ .statusCode(200)
+ .body("active", is(true))
+ .body("subject", notNullValue())
+ .body("cacheSize", is(0));
+ }
+
+ @Test
+ @Order(21)
+ public void testIntrospector_noCache_invalidToken_returnsInactive() {
+ given()
+ .queryParam("accessToken", "invalid.token")
+ .queryParam("clientId", config("test.client.id"))
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+ .get("/keycloak/introspection-cache/introspector/none")
+ .then()
+ .statusCode(200)
+ .body("active", is(false));
+ }
+
+ @Test
+ @Order(22)
+ public void testIntrospector_caffeineStats_secondCallHitsCache() {
+ given()
+ .queryParam("accessToken", normalUserToken)
+ .queryParam("clientId", config("test.client.id"))
+ .queryParam("clientSecret", TEST_CLIENT_SECRET)
+
.get("/keycloak/introspection-cache/introspector/caffeine-stats")
+ .then()
+ .statusCode(200)
+ .body("hitCount", is(1))
+ .body("missCount", is(1))
+ .body("hitRate", is(0.5f))
+ .body("cacheSize", greaterThan(0));
+ }
+
+ @Test
+ @Order(23)
+ public void testIntrospector_noCache_invalidToken_returns500() {
+ given()
+ .queryParam("accessToken", "invalid.token")
+ .queryParam("clientId", config("test.client.id"))
+ .queryParam("clientSecret", "wrong.secret")
+ .get("/keycloak/introspection-cache/introspector/none")
+ .then()
+ .statusCode(500);
+ }
+
+ @Test
+ @Order(100)
+ public void testCleanup_DeleteRealm() {
+ KeycloakRealmLifecycle.deleteRealm(config("test.realm"));
+ }
+
+ protected void createRealm() {
+ KeycloakRealmLifecycle.createRealmWithSmtp(config("test.realm"));
+ }
+
+ protected void createClient() {
+ ClientRepresentation client = new ClientRepresentation();
+ client.setClientId(config("test.client.id"));
+ client.setSecret(TEST_CLIENT_SECRET);
+ client.setPublicClient(false);
+ client.setDirectAccessGrantsEnabled(true);
+ client.setStandardFlowEnabled(true);
+ client.setFullScopeAllowed(true);
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(client)
+ .post("/keycloak/client/{realmName}/pojo",
config("test.realm"))
+ .then()
+ .statusCode(201);
+ }
+
+ protected void createRole(String roleName) {
+ given()
+ .queryParam("description", "Test role for integration testing")
+ .when()
+ .post("/keycloak/role/{realmName}/{roleName}",
config("test.realm"), roleName)
+ .then()
+ .statusCode(200);
+ }
+
+ protected void createUser(String username) {
+ UserRepresentation user = new UserRepresentation();
+ user.setUsername(username);
+ user.setEmail(username + "@test.com");
+ user.setFirstName(username);
+ user.setLastName("User");
+ user.setEnabled(true);
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(List.of(user))
+ .when()
+ .post("/keycloak/user/{realmName}", config("test.realm"))
+ .then()
+ .statusCode(200);
+ }
+
+ private void resetPassword(String username, String password) {
+ given()
+ .queryParam("password", password)
+ .queryParam("temporary", false)
+ .when()
+ .post("/keycloak/user/{realmName}/{username}/reset-password",
config("test.realm"), username)
+ .then()
+ .statusCode(200);
+ }
+
+ protected void assignRole(String username, String role) {
+ given()
+ .when()
+ .post("/keycloak/user-role/{realmName}/{username}/{roleName}",
+ config("test.realm"), username, role)
+ .then()
+ .statusCode(200);
+ }
+}
diff --git
a/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakSecurityPolicyTestBase.java
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakSecurityPolicyTestBase.java
new file mode 100644
index 0000000000..c8f682bd2c
--- /dev/null
+++
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakSecurityPolicyTestBase.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.camel.quarkus.component.keycloak.it;
+
+import java.util.UUID;
+
+import io.quarkus.test.common.QuarkusTestResource;
+
+@QuarkusTestResource(KeycloakTestResource.class)
+public class KeycloakSecurityPolicyTestBase extends KeycloakTestBase {
+ // Test users
+ protected static final String ADMIN_USER = "admin-" +
UUID.randomUUID().toString().substring(0, 8);
+ protected static final String ADMIN_PASSWORD = "admin123";
+ protected static final String NORMAL_USER = "user-" +
UUID.randomUUID().toString().substring(0, 8);
+ protected static final String NORMAL_PASSWORD = "user123";
+ protected static final String ATTACKER_USER = "attacker-" +
UUID.randomUUID().toString().substring(0, 8);
+ protected static final String ATTACKER_PASSWORD = "attacker123";
+
+ // Test roles
+ protected static final String ADMIN_ROLE = "admin";
+ protected static final String USER_ROLE = "user";
+}
diff --git
a/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakTestBase.java
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakTestBase.java
index b3e228d5b8..9d8660b7ac 100644
---
a/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakTestBase.java
+++
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakTestBase.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel.quarkus.component.keycloak.it;
+import java.util.Map;
import java.util.UUID;
import com.fasterxml.jackson.databind.DeserializationFeature;
@@ -24,6 +25,13 @@ import io.quarkus.test.common.QuarkusTestResource;
import io.restassured.RestAssured;
import io.restassured.config.ObjectMapperConfig;
import io.restassured.config.RestAssuredConfig;
+import jakarta.ws.rs.client.Client;
+import jakarta.ws.rs.client.ClientBuilder;
+import jakarta.ws.rs.client.Entity;
+import jakarta.ws.rs.core.Form;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import org.eclipse.microprofile.config.ConfigProvider;
import org.junit.jupiter.api.BeforeAll;
/**
@@ -36,9 +44,11 @@ public abstract class KeycloakTestBase {
// Test data - use unique names to avoid conflicts
protected static final String TEST_REALM_NAME = "test-realm-" +
UUID.randomUUID().toString().substring(0, 8);
protected static final String TEST_USER_NAME = "test-user-" +
UUID.randomUUID().toString().substring(0, 8);
+ protected static final String TEST_USER_PASSWORD = "Test@password123";
protected static final String TEST_ROLE_NAME = "test-role-" +
UUID.randomUUID().toString().substring(0, 8);
protected static final String TEST_GROUP_NAME = "test-group-" +
UUID.randomUUID().toString().substring(0, 8);
protected static final String TEST_CLIENT_ID = "test-client-" +
UUID.randomUUID().toString().substring(0, 8);
+ protected static final String TEST_CLIENT_SECRET = "test-client-secret";
protected static final String TEST_CLIENT_ROLE_NAME = "test-client-role-"
+ UUID.randomUUID().toString().substring(0, 8);
protected static final String TEST_CLIENT_SCOPE_NAME = "test-scope-" +
UUID.randomUUID().toString().substring(0, 8);
@@ -62,4 +72,36 @@ public abstract class KeycloakTestBase {
return mapper;
}));
}
+
+ protected String config(String name) {
+ return ConfigProvider.getConfig().getValue(name, String.class);
+ }
+
+ protected String getAccessToken(String username, String password,
+ String clientId, String clientSecret) {
+ try (Client client = ClientBuilder.newClient()) {
+ String tokenUrl =
String.format("%s/realms/%s/protocol/openid-connect/token",
+ config("keycloak.url"), config("test.realm"));
+
+ Form form = new Form()
+ .param("grant_type", "password")
+ .param("client_id", clientId)
+ .param("client_secret", clientSecret)
+ .param("username", username)
+ .param("password", password);
+
+ try (Response response = client.target(tokenUrl)
+ .request(MediaType.APPLICATION_JSON)
+ .post(Entity.entity(form,
MediaType.APPLICATION_FORM_URLENCODED))) {
+
+ if (response.getStatus() == 200) {
+ @SuppressWarnings("unchecked")
+ Map<String, Object> body = response.readEntity(Map.class);
+ return (String) body.get("access_token");
+ }
+ throw new RuntimeException("Failed to get token for " +
username
+ + " [" + response.getStatus() + "]: " +
response.readEntity(String.class));
+ }
+ }
+ }
}
diff --git
a/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakTestResource.java
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakTestResource.java
index e043482905..d65de24760 100644
---
a/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakTestResource.java
+++
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakTestResource.java
@@ -19,6 +19,7 @@ package org.apache.camel.quarkus.component.keycloak.it;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
+import java.util.UUID;
import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;
import io.quarkus.test.keycloak.server.KeycloakContainer;
@@ -70,6 +71,9 @@ public class KeycloakTestResource implements
QuarkusTestResourceLifecycleManager
properties.put("keycloak.username", "admin");
properties.put("keycloak.password", "admin");
properties.put("keycloak.realm", "master");
+ properties.put("test.client.secret", "test-client-secret");
+ properties.put("test.client.id", "token-binding-client-" +
UUID.randomUUID().toString().substring(0, 8));
+ properties.put("test.realm", "token-binding-realm-" +
UUID.randomUUID().toString().substring(0, 8));
// GreenMail SMTP configuration (accessible from host)
properties.put("mail.smtp.host", greenMail.getHost());
diff --git
a/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakUserTest.java
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakUserTest.java
index 92fd49e6c9..5c1e1e7bae 100644
---
a/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakUserTest.java
+++
b/integration-tests/keycloak/src/test/java/org/apache/camel/quarkus/component/keycloak/it/KeycloakUserTest.java
@@ -16,22 +16,29 @@
*/
package org.apache.camel.quarkus.component.keycloak.it;
+import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.stream.Collectors;
import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.common.mapper.TypeRef;
import io.restassured.http.ContentType;
-import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import static io.restassured.RestAssured.given;
+import static org.hamcrest.CoreMatchers.hasItem;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
+import static org.hamcrest.Matchers.hasItems;
@QuarkusTest
@QuarkusTestResource(KeycloakTestResource.class)
@@ -391,6 +398,161 @@ class KeycloakUserTest extends KeycloakTestBase {
.body(is("Actions email sent successfully"));
}
+ @Test
+ @Order(21)
+ public void testBulkCreateUsers() {
+ String bulkUserNameOne = TEST_USER_NAME + "-bulkOne";
+
+ UserRepresentation userOne = new UserRepresentation();
+ userOne.setUsername(bulkUserNameOne);
+ userOne.setEmail(bulkUserNameOne + "@test.com");
+ userOne.setFirstName("Test One");
+ userOne.setLastName("User Bulk One");
+ userOne.setEnabled(true);
+
+ String bulkUserNameTwo = TEST_USER_NAME + "-bulkTwo";
+
+ UserRepresentation userTwo = new UserRepresentation();
+ userTwo.setUsername(bulkUserNameTwo);
+ userTwo.setEmail(bulkUserNameTwo + "@test.com");
+ userTwo.setFirstName("Test Two");
+ userTwo.setLastName("User Bulk Two");
+ userTwo.setEnabled(true);
+
+ List<UserRepresentation> users = new ArrayList<>();
+ users.add(userOne);
+ users.add(userTwo);
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(users)
+ .when()
+ .post("/keycloak/user/{realmName}", TEST_REALM_NAME)
+ .then()
+ .statusCode(200)
+ .body("total", is(2))
+ .body("success", is(2))
+ .body("results.username", hasItems(bulkUserNameOne,
bulkUserNameTwo))
+ .body("results.status", hasItem("success"));
+ }
+
+ @Test
+ @Order(22)
+ public void testBulkUpdateUsers() {
+ //First get list of users
+ List<UserRepresentation> batchCreatedUsers = given()
+ .when()
+ .get("/keycloak/user/{realmName}", TEST_REALM_NAME)
+ .then()
+ .statusCode(200)
+ .contentType(ContentType.JSON)
+ .extract()
+ .body()
+ .jsonPath()
+ .getList(".", UserRepresentation.class).stream().filter(user
-> user.getUsername().contains("bulk")).toList();
+
+ //update firstname and lastname of each user
+ batchCreatedUsers.forEach(user -> {
+ user.setFirstName("updatedFirstNameForBulkCreatedUsers");
+ user.setLastName("updatedLastNameForBulkCreatedUsers");
+ });
+
+ //bulk update users
+ given()
+ .contentType(ContentType.JSON)
+ .body(batchCreatedUsers)
+ .when()
+ .put("/keycloak/user/{realmName}", TEST_REALM_NAME)
+ .then()
+ .statusCode(200)
+ .body("total", is(batchCreatedUsers.size()))
+ .body("success", is(batchCreatedUsers.size()));
+
+ //verify updated result
+ List<UserRepresentation> updatedUsers = given()
+ .when()
+ .get("/keycloak/user/{realmName}", TEST_REALM_NAME)
+ .then()
+ .statusCode(200)
+ .contentType(ContentType.JSON)
+ .extract()
+ .body()
+ .jsonPath()
+ .getList(".", UserRepresentation.class).stream().filter(user
-> user.getUsername().contains("bulk")).toList();
+
+ Map<String, UserRepresentation> updatedUserMap = updatedUsers.stream()
+ .collect(Collectors.toMap(UserRepresentation::getUsername, u
-> u));
+
+ for (UserRepresentation user : batchCreatedUsers) {
+ UserRepresentation updatedUser =
updatedUserMap.get(user.getUsername());
+ assertThat(updatedUser, notNullValue());
+ assertThat(updatedUser.getFirstName(),
is("updatedFirstNameForBulkCreatedUsers"));
+ assertThat(updatedUser.getLastName(),
is("updatedLastNameForBulkCreatedUsers"));
+ }
+ }
+
+ @Test
+ @Order(23)
+ public void testBulkUpdateUsersWithContinueOnError() {
+ //First get list of users
+ List<UserRepresentation> batchCreatedUsers = new ArrayList<>(given()
+ .when()
+ .get("/keycloak/user/{realmName}", TEST_REALM_NAME)
+ .then()
+ .statusCode(200)
+ .contentType(ContentType.JSON)
+ .extract()
+ .body()
+ .jsonPath()
+ .getList(".", UserRepresentation.class).stream().filter(user
-> user.getUsername().contains("bulk")).toList());
+
+ //add a wrong user at the beginning, even failed to handle but still
keep processing
+ batchCreatedUsers.add(0, new UserRepresentation());
+
+ //bulk update users
+ given()
+ .contentType(ContentType.JSON)
+ .body(batchCreatedUsers)
+ .header("continueOnError", true)
+ .when()
+ .put("/keycloak/user/{realmName}", TEST_REALM_NAME)
+ .then()
+ .statusCode(200)
+ .body("total", is(batchCreatedUsers.size()))
+ .body("success", is(2))
+ .body("failed", is(1));
+ }
+
+ @Test
+ @Order(24)
+ public void testBulkDeleteUsers() {
+ // First get list of users
+ List<String> batchCreatedUsers = given()
+ .when()
+ .get("/keycloak/user/{realmName}", TEST_REALM_NAME)
+ .then()
+ .statusCode(200)
+ .contentType(ContentType.JSON)
+ .extract()
+ .body()
+ .jsonPath()
+ .getList(".",
UserRepresentation.class).stream().map(UserRepresentation::getUsername)
+ .filter(username -> username.contains("bulk")).toList();
+
+ // Bulk delete users
+ given()
+ .when()
+ .contentType(ContentType.JSON)
+ .body(batchCreatedUsers)
+ .delete("/keycloak/user/{realmName}", TEST_REALM_NAME)
+ .then()
+ .statusCode(200)
+ .body("total", is(batchCreatedUsers.size()))
+ .body("success", is(batchCreatedUsers.size()))
+ .body("results.size()", is(batchCreatedUsers.size()));
+
+ }
+
@Test
@Order(100)
public void testCleanupUsers() {