This is an automated email from the ASF dual-hosted git repository.
acosentino pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 59776a6c266f CAMEL-22724 - Camel-Keycloak: Enhance component with
configurable token source priority and binding validation (#20053)
59776a6c266f is described below
commit 59776a6c266f2316e2a607ec925749c892cc60a1
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Nov 25 15:40:00 2025 +0100
CAMEL-22724 - Camel-Keycloak: Enhance component with configurable token
source priority and binding validation (#20053)
Signed-off-by: Andrea Cosentino <[email protected]>
---
.../security/KeycloakSecurityConstants.java | 15 +
.../keycloak/security/KeycloakSecurityPolicy.java | 40 ++
.../security/KeycloakSecurityProcessor.java | 145 ++++++-
.../security/KeycloakTokenBindingSecurityIT.java | 450 +++++++++++++++++++++
4 files changed, 639 insertions(+), 11 deletions(-)
diff --git
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityConstants.java
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityConstants.java
index adf70f6da432..09bb78763fc8 100644
---
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityConstants.java
+++
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityConstants.java
@@ -27,6 +27,21 @@ public final class KeycloakSecurityConstants {
public static final String USER_ROLES_HEADER = "CamelKeycloakUserRoles";
public static final String USER_ROLES_PROPERTY = "CamelKeycloakUserRoles";
+ /**
+ * Session ID property for binding tokens to specific sessions (prevents
session fixation)
+ */
+ public static final String SESSION_ID_PROPERTY = "CamelKeycloakSessionId";
+
+ /**
+ * Token thumbprint property for validating token integrity (SHA-256 hash)
+ */
+ public static final String TOKEN_THUMBPRINT_PROPERTY =
"CamelKeycloakTokenThumbprint";
+
+ /**
+ * Token subject (user ID) for binding validation
+ */
+ public static final String TOKEN_SUBJECT_PROPERTY =
"CamelKeycloakTokenSubject";
+
private KeycloakSecurityConstants() {
// Utility class
}
diff --git
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicy.java
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicy.java
index 6b31f3c4b287..5d199c4d0a0d 100644
---
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicy.java
+++
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicy.java
@@ -66,6 +66,22 @@ public class KeycloakSecurityPolicy implements
AuthorizationPolicy {
* Time-to-live for cached introspection results in seconds. Default is 60
seconds.
*/
private long introspectionCacheTtl = 60;
+ /**
+ * Enable validation that tokens are bound to specific sessions. When
enabled, tokens from headers must match the
+ * session-bound token to prevent session fixation attacks. Default is
true for security.
+ */
+ private boolean validateTokenBinding = true;
+ /**
+ * Allow token retrieval from HTTP headers. When disabled, tokens can only
come from exchange properties. Disabling
+ * this prevents attackers from injecting tokens via headers. Default is
true for backward compatibility, but should
+ * be set to false in production environments where tokens are set
programmatically.
+ */
+ private boolean allowTokenFromHeader = true;
+ /**
+ * Prefer tokens from exchange properties over headers. When true, if a
token exists in both property and header,
+ * the property value is used. This prevents header-based token override
attacks. Default is true for security.
+ */
+ private boolean preferPropertyOverHeader = true;
private Keycloak keycloakClient;
private KeycloakTokenIntrospector tokenIntrospector;
@@ -333,4 +349,28 @@ public class KeycloakSecurityPolicy implements
AuthorizationPolicy {
public KeycloakTokenIntrospector getTokenIntrospector() {
return tokenIntrospector;
}
+
+ public boolean isValidateTokenBinding() {
+ return validateTokenBinding;
+ }
+
+ public void setValidateTokenBinding(boolean validateTokenBinding) {
+ this.validateTokenBinding = validateTokenBinding;
+ }
+
+ public boolean isAllowTokenFromHeader() {
+ return allowTokenFromHeader;
+ }
+
+ public void setAllowTokenFromHeader(boolean allowTokenFromHeader) {
+ this.allowTokenFromHeader = allowTokenFromHeader;
+ }
+
+ public boolean isPreferPropertyOverHeader() {
+ return preferPropertyOverHeader;
+ }
+
+ public void setPreferPropertyOverHeader(boolean preferPropertyOverHeader) {
+ this.preferPropertyOverHeader = preferPropertyOverHeader;
+ }
}
diff --git
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessor.java
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessor.java
index 1787ee6746be..9faec6822884 100644
---
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessor.java
+++
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessor.java
@@ -16,6 +16,10 @@
*/
package org.apache.camel.component.keycloak.security;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
import java.util.Set;
import org.apache.camel.CamelAuthorizationException;
@@ -69,26 +73,145 @@ public class KeycloakSecurityProcessor extends
DelegateProcessor {
}
}
- private String getAccessToken(Exchange exchange) {
- // Try to get token from header first
- String token =
exchange.getIn().getHeader(KeycloakSecurityConstants.ACCESS_TOKEN_HEADER,
String.class);
+ private String getAccessToken(Exchange exchange) throws Exception {
+ // Get token from exchange property (application-controlled, TRUSTED)
+ String propertyToken =
exchange.getProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
String.class);
+ String headerToken = null;
- if (token == null) {
- // Try to get from Authorization header
- String authHeader = exchange.getIn().getHeader("Authorization",
String.class);
- if (authHeader != null && authHeader.startsWith("Bearer ")) {
- token = authHeader.substring(7);
+ // Get token from headers only if allowed by policy
+ if (policy.isAllowTokenFromHeader()) {
+ headerToken =
exchange.getIn().getHeader(KeycloakSecurityConstants.ACCESS_TOKEN_HEADER,
String.class);
+
+ if (headerToken == null) {
+ // Try to get from Authorization header
+ String authHeader =
exchange.getIn().getHeader("Authorization", String.class);
+ if (authHeader != null && authHeader.startsWith("Bearer ")) {
+ headerToken = authHeader.substring(7);
+ }
+ }
+ }
+
+ // Determine which token to use based on policy
+ String token;
+ boolean isHeaderSource = false;
+
+ if (policy.isPreferPropertyOverHeader()) {
+ // SECURE DEFAULT: Property (trusted) takes precedence over header
(untrusted)
+ if (propertyToken != null) {
+ token = propertyToken;
+ LOG.debug("Using token from exchange property (preferred
source)");
+ } else if (headerToken != null) {
+ token = headerToken;
+ isHeaderSource = true;
+ LOG.warn("Using token from HTTP header - this may be a
security risk. "
+ + "Consider setting tokens via exchange properties
instead to prevent token injection attacks.");
+ } else {
+ token = null;
+ }
+ } else {
+ // LEGACY MODE (LESS SECURE): Header takes precedence - maintains
backward compatibility
+ if (headerToken != null) {
+ token = headerToken;
+ isHeaderSource = true;
+ LOG.warn("Token from header takes precedence over property -
this may allow token override attacks. "
+ + "Consider setting preferPropertyOverHeader=true for
better security.");
+ } else if (propertyToken != null) {
+ token = propertyToken;
+ LOG.debug("Using token from exchange property");
+ } else {
+ token = null;
}
}
- if (token == null) {
- // Try to get from exchange property
- token =
exchange.getProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
String.class);
+ // Validate token binding if enabled and token came from headers
+ if (token != null && policy.isValidateTokenBinding() &&
isHeaderSource) {
+ validateTokenBinding(exchange, token, propertyToken);
}
return token;
}
+ /**
+ * Validates that a token from headers matches the session-bound token to
prevent session fixation attacks. This
+ * method performs three levels of validation: 1. Token exact match -
header token must match property token if both
+ * exist 2. Subject validation - token subject (user ID) must match stored
subject 3. Thumbprint validation - token
+ * integrity check via SHA-256 hash
+ *
+ * @param exchange the current exchange
+ * @param headerToken token retrieved from HTTP headers
+ * @param propertyToken token stored in exchange properties
(session-bound)
+ * @throws CamelAuthorizationException if token binding validation fails
+ */
+ private void validateTokenBinding(Exchange exchange, String headerToken,
String propertyToken)
+ throws Exception {
+
+ // Level 1: Exact token match validation
+ if (propertyToken != null && !propertyToken.equals(headerToken)) {
+ LOG.error("SECURITY: Token binding validation failed - header
token does not match session-bound property token");
+ throw new CamelAuthorizationException(
+ "Token mismatch detected - possible session fixation or
token injection attack", exchange);
+ }
+
+ // Level 2: Subject (user ID) validation
+ String storedSubject =
exchange.getProperty(KeycloakSecurityConstants.TOKEN_SUBJECT_PROPERTY,
String.class);
+ if (storedSubject != null) {
+ try {
+ // Parse token to extract subject (without full validation -
just for binding check)
+ AccessToken accessToken =
KeycloakSecurityHelper.parseAccessToken(headerToken);
+ String currentSubject = accessToken.getSubject();
+
+ if (!storedSubject.equals(currentSubject)) {
+ LOG.error("SECURITY: Token subject mismatch - expected
user '{}' but token is for user '{}'",
+ storedSubject, currentSubject);
+ throw new CamelAuthorizationException(
+ "Token subject mismatch - token does not belong to
current session user", exchange);
+ }
+
+ LOG.debug("Token subject validation successful - subject '{}'
matches session", storedSubject);
+ } catch (Exception e) {
+ if (e instanceof CamelAuthorizationException) {
+ throw e;
+ }
+ LOG.error("SECURITY: Failed to validate token subject
binding", e);
+ throw new CamelAuthorizationException(
+ "Token binding validation failed - unable to parse
token", exchange,
+ e);
+ }
+ }
+
+ // Level 3: Token thumbprint (integrity) validation
+ String storedThumbprint
+ =
exchange.getProperty(KeycloakSecurityConstants.TOKEN_THUMBPRINT_PROPERTY,
String.class);
+ if (storedThumbprint != null) {
+ String currentThumbprint = calculateTokenThumbprint(headerToken);
+ if (!storedThumbprint.equals(currentThumbprint)) {
+ LOG.error(
+ "SECURITY: Token thumbprint mismatch - token has been
tampered with, replaced, or belongs to different session");
+ throw new CamelAuthorizationException(
+ "Token integrity check failed - possible token
tampering or replacement attack", exchange);
+ }
+ LOG.debug("Token thumbprint validation successful - token
integrity verified");
+ }
+ }
+
+ /**
+ * Calculates a SHA-256 thumbprint of the token for integrity validation.
This provides a cryptographic fingerprint
+ * that can detect if a token has been tampered with or replaced.
+ *
+ * @param token the access token string
+ * @return Base64-encoded SHA-256 hash of the token
+ * @throws IllegalStateException if SHA-256 algorithm is not available
(should never happen)
+ */
+ private String calculateTokenThumbprint(String token) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash =
digest.digest(token.getBytes(StandardCharsets.UTF_8));
+ return Base64.getEncoder().encodeToString(hash);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 algorithm not available -
this should never happen", e);
+ }
+ }
+
private void validateRoles(String accessToken, Exchange exchange) throws
Exception {
try {
Set<String> userRoles;
diff --git
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakTokenBindingSecurityIT.java
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakTokenBindingSecurityIT.java
new file mode 100644
index 000000000000..2ad15e060cff
--- /dev/null
+++
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakTokenBindingSecurityIT.java
@@ -0,0 +1,450 @@
+/*
+ * 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.component.keycloak.security;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+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.apache.camel.CamelAuthorizationException;
+import org.apache.camel.CamelExecutionException;
+import org.apache.camel.RoutesBuilder;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.infra.keycloak.services.KeycloakService;
+import org.apache.camel.test.infra.keycloak.services.KeycloakServiceFactory;
+import org.apache.camel.test.junit5.CamelTestSupport;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+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.junit.jupiter.api.extension.RegisterExtension;
+import org.keycloak.admin.client.Keycloak;
+import org.keycloak.admin.client.KeycloakBuilder;
+import org.keycloak.admin.client.resource.ClientResource;
+import org.keycloak.admin.client.resource.RealmResource;
+import org.keycloak.admin.client.resource.UserResource;
+import org.keycloak.representations.idm.ClientRepresentation;
+import org.keycloak.representations.idm.CredentialRepresentation;
+import org.keycloak.representations.idm.RealmRepresentation;
+import org.keycloak.representations.idm.RoleRepresentation;
+import org.keycloak.representations.idm.UserRepresentation;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Integration test for Keycloak token binding security features.
+ * <p>
+ * This test validates security enhancements that prevent token injection
attacks: - Token source priority (property vs
+ * header) - Token binding validation - Session fixation prevention - Attack
scenario blocking
+ */
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+public class KeycloakTokenBindingSecurityIT extends CamelTestSupport {
+
+ private static final Logger log =
LoggerFactory.getLogger(KeycloakTokenBindingSecurityIT.class);
+
+ @RegisterExtension
+ static KeycloakService keycloakService =
KeycloakServiceFactory.createService();
+
+ // Test data - use unique names
+ private static final String TEST_REALM_NAME = "token-binding-realm-" +
UUID.randomUUID().toString().substring(0, 8);
+ private static final String TEST_CLIENT_ID = "token-binding-client-" +
UUID.randomUUID().toString().substring(0, 8);
+ private static String TEST_CLIENT_SECRET = null;
+
+ // Test users
+ private static final String ADMIN_USER = "admin-" +
UUID.randomUUID().toString().substring(0, 8);
+ private static final String ADMIN_PASSWORD = "admin123";
+ private static final String NORMAL_USER = "user-" +
UUID.randomUUID().toString().substring(0, 8);
+ private static final String NORMAL_PASSWORD = "user123";
+ private static final String ATTACKER_USER = "attacker-" +
UUID.randomUUID().toString().substring(0, 8);
+ private static final String ATTACKER_PASSWORD = "attacker123";
+
+ // Test roles
+ private static final String ADMIN_ROLE = "admin";
+ private static final String USER_ROLE = "user";
+
+ private static Keycloak keycloakAdminClient;
+ private static RealmResource realmResource;
+
+ @BeforeAll
+ static void setupKeycloakRealm() {
+ log.info("Setting up Keycloak realm for token binding tests");
+
+ keycloakAdminClient = KeycloakBuilder.builder()
+ .serverUrl(keycloakService.getKeycloakServerUrl())
+ .realm(keycloakService.getKeycloakRealm())
+ .username(keycloakService.getKeycloakUsername())
+ .password(keycloakService.getKeycloakPassword())
+ .clientId("admin-cli")
+ .build();
+
+ createTestRealm();
+ realmResource = keycloakAdminClient.realm(TEST_REALM_NAME);
+ createTestClient();
+ createTestRoles();
+ createTestUsers();
+ assignRolesToUsers();
+
+ log.info("Keycloak realm setup completed: {}", TEST_REALM_NAME);
+ }
+
+ @AfterAll
+ static void cleanupKeycloakRealm() {
+ if (keycloakAdminClient != null) {
+ try {
+ keycloakAdminClient.realm(TEST_REALM_NAME).remove();
+ log.info("Deleted test realm: {}", TEST_REALM_NAME);
+ } catch (Exception e) {
+ log.warn("Failed to cleanup test realm: {}", e.getMessage());
+ } finally {
+ keycloakAdminClient.close();
+ }
+ }
+ }
+
+ private static void createTestRealm() {
+ RealmRepresentation realm = new RealmRepresentation();
+ realm.setId(TEST_REALM_NAME);
+ realm.setRealm(TEST_REALM_NAME);
+ realm.setDisplayName("Token Binding Security Test Realm");
+ realm.setEnabled(true);
+ realm.setAccessTokenLifespan(3600);
+ keycloakAdminClient.realms().create(realm);
+ }
+
+ private static void createTestClient() {
+ ClientRepresentation client = new ClientRepresentation();
+ client.setClientId(TEST_CLIENT_ID);
+ client.setName("Token Binding Test Client");
+ client.setDescription("Client for token binding security testing");
+ client.setEnabled(true);
+ client.setPublicClient(false);
+ client.setDirectAccessGrantsEnabled(true);
+ client.setStandardFlowEnabled(true);
+ client.setFullScopeAllowed(true);
+
+ Response response = realmResource.clients().create(client);
+ if (response.getStatus() == 201) {
+ String clientId
+ =
response.getHeaderString("Location").substring(response.getHeaderString("Location").lastIndexOf('/')
+ 1);
+ ClientResource clientResource =
realmResource.clients().get(clientId);
+ TEST_CLIENT_SECRET = clientResource.getSecret().getValue();
+ log.info("Created test client: {} with secret", TEST_CLIENT_ID);
+ } else {
+ throw new RuntimeException("Failed to create client. Status: " +
response.getStatus());
+ }
+ response.close();
+ }
+
+ private static void createTestRoles() {
+ RoleRepresentation adminRole = new RoleRepresentation();
+ adminRole.setName(ADMIN_ROLE);
+ realmResource.roles().create(adminRole);
+
+ RoleRepresentation userRole = new RoleRepresentation();
+ userRole.setName(USER_ROLE);
+ realmResource.roles().create(userRole);
+ }
+
+ private static void createTestUsers() {
+ createUser(ADMIN_USER, ADMIN_PASSWORD, "Admin", "User", ADMIN_USER +
"@test.com");
+ createUser(NORMAL_USER, NORMAL_PASSWORD, "Normal", "User", NORMAL_USER
+ "@test.com");
+ createUser(ATTACKER_USER, ATTACKER_PASSWORD, "Attacker", "User",
ATTACKER_USER + "@test.com");
+ }
+
+ private static void createUser(String username, String password, String
firstName, String lastName, String email) {
+ UserRepresentation user = new UserRepresentation();
+ user.setUsername(username);
+ user.setFirstName(firstName);
+ user.setLastName(lastName);
+ user.setEmail(email);
+ user.setEnabled(true);
+ user.setEmailVerified(true);
+
+ Response response = realmResource.users().create(user);
+ if (response.getStatus() == 201) {
+ String userId
+ =
response.getHeaderString("Location").substring(response.getHeaderString("Location").lastIndexOf('/')
+ 1);
+ UserResource userResource = realmResource.users().get(userId);
+
+ CredentialRepresentation credential = new
CredentialRepresentation();
+ credential.setType(CredentialRepresentation.PASSWORD);
+ credential.setValue(password);
+ credential.setTemporary(false);
+ userResource.resetPassword(credential);
+ log.info("Created user: {} with password", username);
+ } else {
+ throw new RuntimeException("Failed to create user: " + username +
". Status: " + response.getStatus());
+ }
+ response.close();
+ }
+
+ private static void assignRolesToUsers() {
+ assignRoleToUser(ADMIN_USER, ADMIN_ROLE);
+ assignRoleToUser(NORMAL_USER, USER_ROLE);
+ assignRoleToUser(ATTACKER_USER, USER_ROLE);
+ }
+
+ private static void assignRoleToUser(String username, String roleName) {
+ List<UserRepresentation> users =
realmResource.users().search(username);
+ if (!users.isEmpty()) {
+ UserResource userResource =
realmResource.users().get(users.get(0).getId());
+ RoleRepresentation role =
realmResource.roles().get(roleName).toRepresentation();
+ userResource.roles().realmLevel().add(Arrays.asList(role));
+ }
+ }
+
+ @Override
+ protected RoutesBuilder createRouteBuilder() throws Exception {
+ return new RouteBuilder() {
+ @Override
+ public void configure() throws Exception {
+ // Route 1: Secure default - property preferred over header
+ KeycloakSecurityPolicy securePolicy = new
KeycloakSecurityPolicy();
+
securePolicy.setServerUrl(keycloakService.getKeycloakServerUrl());
+ securePolicy.setRealm(TEST_REALM_NAME);
+ securePolicy.setClientId(TEST_CLIENT_ID);
+ securePolicy.setClientSecret(TEST_CLIENT_SECRET);
+ securePolicy.setPreferPropertyOverHeader(true);
+ securePolicy.setAllowTokenFromHeader(true);
+ securePolicy.setValidateTokenBinding(true);
+
+ from("direct:secure-default")
+ .policy(securePolicy)
+ .transform().constant("Access granted - secure
default");
+
+ // Route 2: Maximum security - headers disabled
+ KeycloakSecurityPolicy maxSecurityPolicy = new
KeycloakSecurityPolicy();
+
maxSecurityPolicy.setServerUrl(keycloakService.getKeycloakServerUrl());
+ maxSecurityPolicy.setRealm(TEST_REALM_NAME);
+ maxSecurityPolicy.setClientId(TEST_CLIENT_ID);
+ maxSecurityPolicy.setClientSecret(TEST_CLIENT_SECRET);
+ maxSecurityPolicy.setAllowTokenFromHeader(false);
+
+ from("direct:max-security")
+ .policy(maxSecurityPolicy)
+ .transform().constant("Access granted - max security");
+
+ // Route 3: Admin-only route
+ KeycloakSecurityPolicy adminPolicy = new
KeycloakSecurityPolicy();
+
adminPolicy.setServerUrl(keycloakService.getKeycloakServerUrl());
+ adminPolicy.setRealm(TEST_REALM_NAME);
+ adminPolicy.setClientId(TEST_CLIENT_ID);
+ adminPolicy.setClientSecret(TEST_CLIENT_SECRET);
+ adminPolicy.setRequiredRoles(Arrays.asList(ADMIN_ROLE));
+ adminPolicy.setPreferPropertyOverHeader(true);
+
+ from("direct:admin-only")
+ .policy(adminPolicy)
+ .transform().constant("Admin access granted");
+ }
+ };
+ }
+
+ @Test
+ @Order(1)
+ void testKeycloakServiceConfiguration() {
+ assertNotNull(keycloakService.getKeycloakServerUrl());
+ assertNotNull(TEST_CLIENT_SECRET);
+ }
+
+ @Test
+ @Order(10)
+ void testPropertyTokenWorks() {
+ String normalToken = getAccessToken(NORMAL_USER, NORMAL_PASSWORD);
+
+ String result = template.send("direct:secure-default", exchange -> {
+
exchange.setProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
normalToken);
+ exchange.getIn().setBody("test");
+ }).getMessage().getBody(String.class);
+
+ assertEquals("Access granted - secure default", result);
+ log.info("✓ Property token works");
+ }
+
+ @Test
+ @Order(11)
+ void testHeaderTokenWorks() {
+ String normalToken = getAccessToken(NORMAL_USER, NORMAL_PASSWORD);
+
+ String result = template.requestBodyAndHeader("direct:secure-default",
"test",
+ KeycloakSecurityConstants.ACCESS_TOKEN_HEADER, normalToken,
String.class);
+
+ assertEquals("Access granted - secure default", result);
+ log.info("✓ Header token works");
+ }
+
+ @Test
+ @Order(12)
+ void testPropertyPreferredOverHeader() {
+ String normalToken = getAccessToken(NORMAL_USER, NORMAL_PASSWORD);
+ String attackerToken = getAccessToken(ATTACKER_USER,
ATTACKER_PASSWORD);
+
+ String result = template.send("direct:secure-default", exchange -> {
+
exchange.setProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
normalToken);
+
exchange.getIn().setHeader(KeycloakSecurityConstants.ACCESS_TOKEN_HEADER,
attackerToken);
+ exchange.getIn().setBody("test");
+ }).getMessage().getBody(String.class);
+
+ assertEquals("Access granted - secure default", result);
+ log.info("✓ Property token preferred over header token");
+ }
+
+ @Test
+ @Order(13)
+ void testInvalidHeaderIgnoredWhenPropertyValid() {
+ String normalToken = getAccessToken(NORMAL_USER, NORMAL_PASSWORD);
+
+ String result = template.send("direct:secure-default", exchange -> {
+
exchange.setProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
normalToken);
+
exchange.getIn().setHeader(KeycloakSecurityConstants.ACCESS_TOKEN_HEADER,
"invalid.token");
+ exchange.getIn().setBody("test");
+ }).getMessage().getBody(String.class);
+
+ assertEquals("Access granted - secure default", result);
+ log.info("✓ Invalid header ignored when property valid");
+ }
+
+ @Test
+ @Order(20)
+ void testHeaderRejectedWhenHeadersDisabled() {
+ String normalToken = getAccessToken(NORMAL_USER, NORMAL_PASSWORD);
+
+ CamelExecutionException ex =
assertThrows(CamelExecutionException.class, () -> {
+ template.requestBodyAndHeader("direct:max-security", "test",
+ KeycloakSecurityConstants.ACCESS_TOKEN_HEADER,
normalToken, String.class);
+ });
+
+ assertTrue(ex.getCause() instanceof CamelAuthorizationException);
+ log.info("✓ Header correctly rejected when headers disabled");
+ }
+
+ @Test
+ @Order(21)
+ void testPropertyWorksWhenHeadersDisabled() {
+ String normalToken = getAccessToken(NORMAL_USER, NORMAL_PASSWORD);
+
+ String result = template.send("direct:max-security", exchange -> {
+
exchange.setProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
normalToken);
+ exchange.getIn().setBody("test");
+ }).getMessage().getBody(String.class);
+
+ assertEquals("Access granted - max security", result);
+ log.info("✓ Property works when headers disabled");
+ }
+
+ @Test
+ @Order(30)
+ void testPropertyTokenUsedNotHeader() {
+ String normalToken = getAccessToken(NORMAL_USER, NORMAL_PASSWORD);
+ String adminToken = getAccessToken(ADMIN_USER, ADMIN_PASSWORD);
+
+ String result = template.send("direct:secure-default", exchange -> {
+
exchange.setProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
normalToken);
+
exchange.getIn().setHeader(KeycloakSecurityConstants.ACCESS_TOKEN_HEADER,
adminToken);
+ exchange.getIn().setBody("test");
+ }).getMessage().getBody(String.class);
+
+ assertEquals("Access granted - secure default", result);
+ log.info("✓ Property token preferred - attack vector mitigated");
+ }
+
+ @Test
+ @Order(31)
+ void testAttackScenario_SessionHijacking() {
+ String victimToken = getAccessToken(NORMAL_USER, NORMAL_PASSWORD);
+ String attackerToken = getAccessToken(ATTACKER_USER,
ATTACKER_PASSWORD);
+
+ String result = template.send("direct:secure-default", exchange -> {
+
exchange.setProperty(KeycloakSecurityConstants.ACCESS_TOKEN_PROPERTY,
victimToken);
+
exchange.getIn().setHeader(KeycloakSecurityConstants.ACCESS_TOKEN_HEADER,
attackerToken);
+ exchange.getIn().setBody("hijack attempt");
+ }).getMessage().getBody(String.class);
+
+ assertEquals("Access granted - secure default", result);
+ log.info("✓ BLOCKED: Session hijacking prevented");
+ }
+
+ @Test
+ @Order(32)
+ void testAuthorizationHeaderFormat() {
+ String normalToken = getAccessToken(NORMAL_USER, NORMAL_PASSWORD);
+
+ String result = template.requestBodyAndHeader("direct:secure-default",
"test",
+ "Authorization", "Bearer " + normalToken, String.class);
+
+ assertEquals("Access granted - secure default", result);
+ log.info("✓ Authorization Bearer header works");
+ }
+
+ @Test
+ @Order(33)
+ void testNoTokenRejected() {
+ CamelExecutionException ex =
assertThrows(CamelExecutionException.class, () -> {
+ template.sendBody("direct:secure-default", "test");
+ });
+
+ assertTrue(ex.getCause() instanceof CamelAuthorizationException);
+ assertTrue(ex.getCause().getMessage().contains("Access token not
found"));
+ log.info("✓ Request without token correctly rejected");
+ }
+
+ private String getAccessToken(String username, String password) {
+ try (Client client = ClientBuilder.newClient()) {
+ String tokenUrl = keycloakService.getKeycloakServerUrl() +
"/realms/" + TEST_REALM_NAME
+ + "/protocol/openid-connect/token";
+
+ Form form = new Form()
+ .param("grant_type", "password")
+ .param("client_id", TEST_CLIENT_ID)
+ .param("client_secret", TEST_CLIENT_SECRET)
+ .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> tokenResponse =
response.readEntity(Map.class);
+ return (String) tokenResponse.get("access_token");
+ } else {
+ String errorBody = response.readEntity(String.class);
+ log.error("Failed to obtain token for user {}. Status: {},
Response: {}", username,
+ response.getStatus(), errorBody);
+ throw new RuntimeException(
+ "Failed to obtain access token for " + username +
". Status: " + response.getStatus() + ", Error: "
+ + errorBody);
+ }
+ }
+ } catch (Exception e) {
+ throw new RuntimeException("Error obtaining access token for " +
username, e);
+ }
+ }
+}