This is an automated email from the ASF dual-hosted git repository. acosentino pushed a commit to branch CAMEL-22724 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 8a14b037bdbdbb8ce393b54145caa287c22940ba Author: Andrea Cosentino <[email protected]> AuthorDate: Tue Nov 25 15:08:39 2025 +0100 CAMEL-22724 - Camel-Keycloak: Enhance component with configurable token source priority and binding validation 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); + } + } +}
