This is an automated email from the ASF dual-hosted git repository.

davsclaus 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 4bad2c9979eb CAMEL-23875: camel-keycloak - add optional audience 
validation
4bad2c9979eb is described below

commit 4bad2c9979eb7f43496b83b00bb1a3d414138405
Author: Adriano Machado <[email protected]>
AuthorDate: Tue Jul 7 01:15:33 2026 -0400

    CAMEL-23875: camel-keycloak - add optional audience validation
    
    Add opt-in expectedAudience option to KeycloakSecurityPolicy so tokens
    whose aud claim does not contain every configured audience are rejected.
    This prevents a token issued for one client from being accepted by routes
    intended for another client in multi-client Keycloak realms. Enforced on
    both local JWT verification (via Keycloak's TokenVerifier.audience) and
    token introspection. Disabled by default for backward compatibility.
    
    Closes #24477
---
 .../src/main/docs/keycloak-security.adoc           |  57 ++++-
 .../keycloak/security/KeycloakSecurityHelper.java  |  27 +++
 .../keycloak/security/KeycloakSecurityPolicy.java  |  41 ++++
 .../security/KeycloakSecurityProcessor.java        |  56 ++++-
 .../security/KeycloakSecurityHelperTest.java       | 122 ++++++++++
 .../security/KeycloakSecurityPolicyTest.java       |  11 +
 .../security/KeycloakSecurityProcessorTest.java    | 257 +++++++++++++++++++++
 7 files changed, 568 insertions(+), 3 deletions(-)

diff --git a/components/camel-keycloak/src/main/docs/keycloak-security.adoc 
b/components/camel-keycloak/src/main/docs/keycloak-security.adoc
index bbbf65e6aad0..d18ac18c1c56 100644
--- a/components/camel-keycloak/src/main/docs/keycloak-security.adoc
+++ b/components/camel-keycloak/src/main/docs/keycloak-security.adoc
@@ -59,7 +59,62 @@ beans:
 NOTE: When an access token is present, it is always verified - signature, 
issuer and expiry for local JWT
 verification, or active state and issuer when token introspection is enabled - 
regardless of whether
 `requiredRoles` or `requiredPermissions` are configured. Role and permission 
checks are applied only after the
-token has been verified. A request without a token is rejected.
+token has been verified. A request without a token is rejected. Audience 
validation (see below) is optional and
+applied in addition to these checks when `expectedAudience` is configured.
+
+=== Audience Validation
+
+By default, the security policy does not check the token's `aud` (audience) 
claim. In a multi-client Keycloak
+realm this means a token issued for one client could be accepted by a route 
intended for another client. Setting
+`expectedAudience` closes this gap: tokens whose `aud` claim does not contain 
every one of the configured
+audiences are rejected, for both local JWT verification and token 
introspection. This matches the behavior of
+Keycloak's own `TokenVerifier.audience(String...)` check.
+
+[tabs]
+====
+Java::
++
+[source,java]
+----
+KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy(
+    "http://localhost:8080";, "my-realm", "my-client", "client-secret");
+
+// Reject tokens that do not have "my-client" in their "aud" claim
+policy.setExpectedAudience("my-client");
+
+from("direct:protected")
+    .policy(policy)
+    .to("mock:result");
+----
+
+YAML::
++
+[source,yaml]
+----
+- route:
+    from:
+      uri: direct:protected
+      steps:
+        - policy:
+            ref: audiencePolicy
+        - to:
+            uri: mock:result
+
+# Bean definition
+beans:
+  - name: audiencePolicy
+    type: org.apache.camel.component.keycloak.security.KeycloakSecurityPolicy
+    properties:
+      serverUrl: "http://localhost:8080";
+      realm: "my-realm"
+      clientId: "my-client"
+      clientSecret: "client-secret"
+      expectedAudience: "my-client"
+----
+====
+
+`expectedAudience` accepts a comma-separated list (e.g., 
`"my-client,my-other-client"`) when a token must carry
+several audiences at once. Disabled by default for backward compatibility.
 
 === Role-based Authorization
 
diff --git 
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelper.java
 
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelper.java
index fa8408d05bbf..5f05ce04373e 100644
--- 
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelper.java
+++ 
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelper.java
@@ -21,6 +21,7 @@ import java.security.PublicKey;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -52,6 +53,26 @@ public final class KeycloakSecurityHelper {
      */
     public static AccessToken parseAndVerifyAccessToken(String tokenString, 
PublicKey publicKey, String expectedIssuer)
             throws VerificationException {
+        return parseAndVerifyAccessToken(tokenString, publicKey, 
expectedIssuer, null);
+    }
+
+    /**
+     * Parses and fully verifies an access token including signature, issuer 
and, optionally, audience validation. This
+     * is the recommended method for secure token validation.
+     *
+     * @param  tokenString           the JWT token string
+     * @param  publicKey             the public key for signature verification
+     * @param  expectedIssuer        the expected issuer URL (e.g., 
"http://localhost:8080/realms/myrealm";)
+     * @param  expectedAudiences     the expected audiences; when non-empty, 
the token's "aud" claim must contain every
+     *                               one of them (matching Keycloak's own 
{@link TokenVerifier#audience(String...)}
+     *                               check). Pass null or an empty list to 
skip audience validation.
+     * @return                       the verified access token
+     * @throws VerificationException if verification fails (invalid signature, 
wrong issuer, expired, missing/wrong
+     *                               audience, etc.)
+     */
+    public static AccessToken parseAndVerifyAccessToken(
+            String tokenString, PublicKey publicKey, String expectedIssuer, 
List<String> expectedAudiences)
+            throws VerificationException {
         if (publicKey == null) {
             throw new VerificationException("Public key is required for secure 
token verification");
         }
@@ -59,6 +80,8 @@ public final class KeycloakSecurityHelper {
             throw new VerificationException("Expected issuer is required for 
secure token verification");
         }
 
+        boolean checkAudience = expectedAudiences != null && 
!expectedAudiences.isEmpty();
+
         TokenVerifier<AccessToken> verifier = 
TokenVerifier.create(tokenString, AccessToken.class)
                 .publicKey(publicKey)
                 .withChecks(
@@ -66,6 +89,10 @@ public final class KeycloakSecurityHelper {
                         TokenVerifier.IS_ACTIVE,
                         new TokenVerifier.RealmUrlCheck(expectedIssuer));
 
+        if (checkAudience) {
+            verifier.audience(expectedAudiences.toArray(new String[0]));
+        }
+
         AccessToken token = verifier.verify().getToken();
 
         // Additional explicit issuer check for defense in depth
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 c1a8f31543fa..17781c45ec21 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
@@ -98,6 +98,13 @@ public class KeycloakSecurityPolicy implements 
AuthorizationPolicy {
      * {serverUrl}/realms/{realm}/protocol/openid-connect/certs. This ensures 
token signatures are properly verified.
      */
     private boolean autoFetchPublicKey = true;
+    /**
+     * Comma-separated list of expected audiences for token validation. When 
set, tokens whose "aud" claim does not
+     * contain every one of the configured audiences are rejected (matching 
Keycloak's own audience verification
+     * behavior). This prevents a token issued for one client from being 
accepted by routes intended for another client
+     * in multi-client Keycloak realms. Disabled by default for backward 
compatibility. Example: "my-client"
+     */
+    private String expectedAudience;
 
     public KeycloakSecurityPolicy() {
         this.requiredRoles = "";
@@ -429,4 +436,38 @@ public class KeycloakSecurityPolicy implements 
AuthorizationPolicy {
     public String getExpectedIssuer() {
         return serverUrl + "/realms/" + realm;
     }
+
+    /**
+     * Gets the expected audience(s) as a comma-separated string.
+     *
+     * @return comma-separated audiences (e.g., "my-client,my-other-client"), 
or null if not configured
+     */
+    public String getExpectedAudience() {
+        return expectedAudience;
+    }
+
+    /**
+     * Sets the expected audience(s) as a comma-separated string. When set, 
tokens whose "aud" claim does not contain
+     * every one of the configured audiences are rejected.
+     *
+     * @param expectedAudience comma-separated audiences (e.g., 
"my-client,my-other-client")
+     */
+    public void setExpectedAudience(String expectedAudience) {
+        this.expectedAudience = expectedAudience;
+    }
+
+    /**
+     * Gets the expected audiences as a list.
+     *
+     * @return list of expected audiences, or an empty list if not configured
+     */
+    public List<String> getExpectedAudienceAsList() {
+        if (ObjectHelper.isEmpty(expectedAudience)) {
+            return Collections.emptyList();
+        }
+        return Arrays.stream(expectedAudience.split(","))
+                .map(String::trim)
+                .filter(s -> !s.isEmpty())
+                .collect(Collectors.toList());
+    }
 }
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 5d5b58ed7a0f..19cb94f70442 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
@@ -22,6 +22,9 @@ import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.security.PublicKey;
 import java.util.Base64;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
 import java.util.Set;
 
 import org.apache.camel.CamelAuthorizationException;
@@ -108,6 +111,10 @@ public class KeycloakSecurityProcessor extends 
DelegateProcessor {
             if (policy.isValidateIssuer()) {
                 validateIssuerFromIntrospection(introspectionResult, exchange);
             }
+
+            if (!policy.getExpectedAudienceAsList().isEmpty()) {
+                validateAudienceFromIntrospection(introspectionResult, 
exchange);
+            }
         } else {
             parseAndVerifyToken(accessToken, exchange);
         }
@@ -272,6 +279,11 @@ public class KeycloakSecurityProcessor extends 
DelegateProcessor {
                     validateIssuerFromIntrospection(introspectionResult, 
exchange);
                 }
 
+                // Validate audience from introspection result if configured
+                if (!policy.getExpectedAudienceAsList().isEmpty()) {
+                    validateAudienceFromIntrospection(introspectionResult, 
exchange);
+                }
+
                 userRoles = 
KeycloakSecurityHelper.extractRolesFromIntrospection(
                         introspectionResult, policy.getRealm(), 
policy.getClientId());
             } else {
@@ -322,10 +334,11 @@ public class KeycloakSecurityProcessor extends 
DelegateProcessor {
             publicKey = policy.getPublicKey();
         }
 
-        // Verify token with public key and issuer validation
+        // Verify token with public key, issuer and (optionally) audience 
validation
         if (publicKey != null) {
             try {
-                return 
KeycloakSecurityHelper.parseAndVerifyAccessToken(accessToken, publicKey, 
expectedIssuer);
+                return KeycloakSecurityHelper.parseAndVerifyAccessToken(
+                        accessToken, publicKey, expectedIssuer, 
policy.getExpectedAudienceAsList());
             } catch (VerificationException e) {
                 LOG.error("Token verification failed: {}", e.getMessage());
                 throw new CamelAuthorizationException("Token verification 
failed: " + e.getMessage(), exchange, e);
@@ -367,6 +380,40 @@ public class KeycloakSecurityProcessor extends 
DelegateProcessor {
         LOG.debug("Issuer validation from introspection successful: {}", 
expectedIssuer);
     }
 
+    /**
+     * Validates the audience from an introspection result. Unlike issuer 
validation, a token whose "aud" claim is
+     * missing is rejected: the whole point of this check is to reject tokens 
that were not issued for this policy's
+     * client(s).
+     */
+    private void validateAudienceFromIntrospection(
+            KeycloakTokenIntrospector.IntrospectionResult introspectionResult, 
Exchange exchange)
+            throws CamelAuthorizationException {
+        List<String> expectedAudiences = policy.getExpectedAudienceAsList();
+        Object audienceClaim = introspectionResult.getClaim("aud");
+
+        Set<String> actualAudiences = new HashSet<>();
+        if (audienceClaim instanceof Collection<?> audienceCollection) {
+            for (Object audience : audienceCollection) {
+                if (audience instanceof String s) {
+                    actualAudiences.add(s);
+                }
+            }
+        } else if (audienceClaim instanceof String s) {
+            actualAudiences.add(s);
+        }
+
+        if (!actualAudiences.containsAll(expectedAudiences)) {
+            LOG.error("SECURITY: Token audience mismatch from introspection - 
expected all of '{}' but got '{}'",
+                    expectedAudiences, actualAudiences);
+            throw new CamelAuthorizationException(
+                    String.format("Token audience mismatch: expected all of 
'%s' but got '%s'",
+                            expectedAudiences, actualAudiences),
+                    exchange);
+        }
+
+        LOG.debug("Audience validation from introspection successful: {}", 
expectedAudiences);
+    }
+
     private void validatePermissions(String accessToken, Exchange exchange) 
throws Exception {
         try {
             Set<String> userPermissions;
@@ -386,6 +433,11 @@ public class KeycloakSecurityProcessor extends 
DelegateProcessor {
                     validateIssuerFromIntrospection(introspectionResult, 
exchange);
                 }
 
+                // Validate audience from introspection result if configured
+                if (!policy.getExpectedAudienceAsList().isEmpty()) {
+                    validateAudienceFromIntrospection(introspectionResult, 
exchange);
+                }
+
                 userPermissions = 
KeycloakSecurityHelper.extractPermissionsFromIntrospection(introspectionResult);
             } else {
                 // Use local JWT parsing with secure verification
diff --git 
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelperTest.java
 
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelperTest.java
index 92d31b94f260..5939b9fbd134 100644
--- 
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelperTest.java
+++ 
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityHelperTest.java
@@ -21,6 +21,7 @@ import java.security.KeyPairGenerator;
 import java.security.PublicKey;
 import java.util.Arrays;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -223,6 +224,127 @@ public class KeycloakSecurityHelperTest {
         assertEquals(expectedIssuer, verified.getIssuer());
     }
 
+    @Test
+    void testParseAndVerifyAccessTokenAcceptsMatchingAudience() throws 
Exception {
+        String expectedIssuer = "http://localhost:8080/realms/test";;
+
+        KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
+        keyGen.initialize(2048);
+        KeyPair keyPair = keyGen.generateKeyPair();
+
+        AccessToken token = new AccessToken();
+        token.issuer(expectedIssuer);
+        token.subject("user-123");
+        token.exp(System.currentTimeMillis() / 1000 + 3600);
+        token.audience("my-client");
+
+        String signed = new JWSBuilder()
+                .type("JWT")
+                .jsonContent(token)
+                .rsa256(keyPair.getPrivate());
+
+        AccessToken verified = 
KeycloakSecurityHelper.parseAndVerifyAccessToken(
+                signed, keyPair.getPublic(), expectedIssuer, 
List.of("my-client"));
+        assertEquals("user-123", verified.getSubject());
+    }
+
+    @Test
+    void testParseAndVerifyAccessTokenAcceptsTokenWithAllExpectedAudiences() 
throws Exception {
+        String expectedIssuer = "http://localhost:8080/realms/test";;
+
+        KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
+        keyGen.initialize(2048);
+        KeyPair keyPair = keyGen.generateKeyPair();
+
+        AccessToken token = new AccessToken();
+        token.issuer(expectedIssuer);
+        token.subject("user-123");
+        token.exp(System.currentTimeMillis() / 1000 + 3600);
+        token.audience("my-client", "my-other-client");
+
+        String signed = new JWSBuilder()
+                .type("JWT")
+                .jsonContent(token)
+                .rsa256(keyPair.getPrivate());
+
+        AccessToken verified = 
KeycloakSecurityHelper.parseAndVerifyAccessToken(
+                signed, keyPair.getPublic(), expectedIssuer, 
List.of("my-client", "my-other-client"));
+        assertEquals("user-123", verified.getSubject());
+    }
+
+    @Test
+    void 
testParseAndVerifyAccessTokenRejectsTokenMissingOneOfMultipleExpectedAudiences()
 throws Exception {
+        String expectedIssuer = "http://localhost:8080/realms/test";;
+
+        KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
+        keyGen.initialize(2048);
+        KeyPair keyPair = keyGen.generateKeyPair();
+
+        AccessToken token = new AccessToken();
+        token.issuer(expectedIssuer);
+        token.subject("user-123");
+        token.exp(System.currentTimeMillis() / 1000 + 3600);
+        // Token only has one of the two expected audiences
+        token.audience("my-client");
+
+        String signed = new JWSBuilder()
+                .type("JWT")
+                .jsonContent(token)
+                .rsa256(keyPair.getPrivate());
+
+        assertThrows(VerificationException.class,
+                () -> KeycloakSecurityHelper.parseAndVerifyAccessToken(
+                        signed, keyPair.getPublic(), expectedIssuer, 
List.of("my-client", "my-other-client")));
+    }
+
+    @Test
+    void testParseAndVerifyAccessTokenRejectsMissingAudience() throws 
Exception {
+        String expectedIssuer = "http://localhost:8080/realms/test";;
+
+        KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
+        keyGen.initialize(2048);
+        KeyPair keyPair = keyGen.generateKeyPair();
+
+        AccessToken token = new AccessToken();
+        token.issuer(expectedIssuer);
+        token.subject("user-123");
+        token.exp(System.currentTimeMillis() / 1000 + 3600);
+        // No audience set on the token
+
+        String signed = new JWSBuilder()
+                .type("JWT")
+                .jsonContent(token)
+                .rsa256(keyPair.getPrivate());
+
+        assertThrows(VerificationException.class,
+                () -> KeycloakSecurityHelper.parseAndVerifyAccessToken(
+                        signed, keyPair.getPublic(), expectedIssuer, 
List.of("my-client")));
+    }
+
+    @Test
+    void testParseAndVerifyAccessTokenRejectsNonMatchingAudience() throws 
Exception {
+        String expectedIssuer = "http://localhost:8080/realms/test";;
+
+        KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
+        keyGen.initialize(2048);
+        KeyPair keyPair = keyGen.generateKeyPair();
+
+        AccessToken token = new AccessToken();
+        token.issuer(expectedIssuer);
+        token.subject("user-123");
+        token.exp(System.currentTimeMillis() / 1000 + 3600);
+        token.audience("some-other-client");
+
+        String signed = new JWSBuilder()
+                .type("JWT")
+                .jsonContent(token)
+                .rsa256(keyPair.getPrivate());
+
+        assertThrows(VerificationException.class,
+                () -> KeycloakSecurityHelper.parseAndVerifyAccessToken(
+                        signed, keyPair.getPublic(), expectedIssuer, 
List.of("my-client")));
+    }
+
     @Test
     void testExtractPermissions() {
         AccessToken token = Mockito.mock(AccessToken.class);
diff --git 
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicyTest.java
 
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicyTest.java
index 7137d9e3e74a..84b92d8da7e3 100644
--- 
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicyTest.java
+++ 
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityPolicyTest.java
@@ -99,6 +99,17 @@ public class KeycloakSecurityPolicyTest extends 
CamelTestSupport {
         assertTrue(ex.getCause() instanceof CamelAuthorizationException);
     }
 
+    @Test
+    void testKeycloakSecurityPolicyWithExpectedAudience() {
+        KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy();
+        assertTrue(policy.getExpectedAudienceAsList().isEmpty(), "Audience 
validation is disabled by default");
+
+        policy.setExpectedAudience("my-client,my-other-client");
+
+        assertEquals("my-client,my-other-client", 
policy.getExpectedAudience());
+        assertEquals(Arrays.asList("my-client", "my-other-client"), 
policy.getExpectedAudienceAsList());
+    }
+
     @Test
     void testResourceOwnerPasswordCredentialsConfiguration() {
         KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy(
diff --git 
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessorTest.java
 
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessorTest.java
index 89fe43304bbb..c3c1c2f8f269 100644
--- 
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessorTest.java
+++ 
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakSecurityProcessorTest.java
@@ -16,8 +16,10 @@
  */
 package org.apache.camel.component.keycloak.security;
 
+import java.security.KeyPair;
 import java.security.KeyPairGenerator;
 import java.security.PublicKey;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
 
@@ -30,9 +32,12 @@ import org.apache.camel.support.DefaultExchange;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.keycloak.jose.jws.JWSBuilder;
+import org.keycloak.representations.AccessToken;
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
  * Verifies that {@link KeycloakSecurityProcessor} authenticates the access 
token even when the policy does not require
@@ -115,4 +120,256 @@ class KeycloakSecurityProcessorTest {
         assertThrows(CamelAuthorizationException.class, () -> 
processor.process(bearer("x")));
         assertFalse(routeReached.get(), "Route body must not be reached for an 
inactive token");
     }
+
+    @Test
+    void testTokenMissingExpectedAudienceRejectedLocalJwt() throws Exception {
+        String issuer = "http://localhost:8080/realms/test-realm";;
+
+        KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+        generator.initialize(2048);
+        KeyPair keyPair = generator.generateKeyPair();
+
+        KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy();
+        policy.setServerUrl("http://localhost:8080";);
+        policy.setRealm("test-realm");
+        policy.setClientId("test-client");
+        policy.setClientSecret("test-secret");
+        policy.setAutoFetchPublicKey(false);
+        policy.setPublicKey(keyPair.getPublic());
+        policy.setExpectedAudience("expected-client");
+
+        AccessToken token = new AccessToken();
+        token.issuer(issuer);
+        token.subject("user-123");
+        token.exp(System.currentTimeMillis() / 1000 + 3600);
+        // No audience on the token
+
+        String signed = new 
JWSBuilder().type("JWT").jsonContent(token).rsa256(keyPair.getPrivate());
+
+        AtomicBoolean routeReached = new AtomicBoolean(false);
+        KeycloakSecurityProcessor processor = new KeycloakSecurityProcessor(e 
-> routeReached.set(true), policy);
+
+        assertThrows(CamelAuthorizationException.class, () -> 
processor.process(bearer(signed)));
+        assertFalse(routeReached.get(), "Route body must not be reached for a 
token missing the expected audience");
+    }
+
+    @Test
+    void testTokenWithMatchingAudienceAcceptedLocalJwt() throws Exception {
+        String issuer = "http://localhost:8080/realms/test-realm";;
+
+        KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+        generator.initialize(2048);
+        KeyPair keyPair = generator.generateKeyPair();
+
+        KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy();
+        policy.setServerUrl("http://localhost:8080";);
+        policy.setRealm("test-realm");
+        policy.setClientId("test-client");
+        policy.setClientSecret("test-secret");
+        policy.setAutoFetchPublicKey(false);
+        policy.setPublicKey(keyPair.getPublic());
+        policy.setExpectedAudience("expected-client");
+
+        AccessToken token = new AccessToken();
+        token.issuer(issuer);
+        token.subject("user-123");
+        token.exp(System.currentTimeMillis() / 1000 + 3600);
+        token.audience("expected-client");
+
+        String signed = new 
JWSBuilder().type("JWT").jsonContent(token).rsa256(keyPair.getPrivate());
+
+        AtomicBoolean routeReached = new AtomicBoolean(false);
+        KeycloakSecurityProcessor processor = new KeycloakSecurityProcessor(e 
-> routeReached.set(true), policy);
+
+        processor.process(bearer(signed));
+        assertTrue(routeReached.get(), "Route body must be reached for a token 
with the expected audience");
+    }
+
+    @Test
+    void testTokenMissingExpectedAudienceRejectedIntrospection() throws 
Exception {
+        KeycloakTokenIntrospector introspector = new KeycloakTokenIntrospector(
+                "http://localhost:8080";, "test-realm", "test-client", 
"test-secret", (TokenCache) null) {
+            @Override
+            public IntrospectionResult introspect(String token) {
+                // Active but no "aud" claim at all
+                return new IntrospectionResult(Map.<String, Object> 
of("active", true));
+            }
+        };
+
+        KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy() {
+            @Override
+            public boolean isUseTokenIntrospection() {
+                return true;
+            }
+
+            @Override
+            public KeycloakTokenIntrospector getTokenIntrospector() {
+                return introspector;
+            }
+        };
+        policy.setServerUrl("http://localhost:8080";);
+        policy.setRealm("test-realm");
+        policy.setClientId("test-client");
+        policy.setClientSecret("test-secret");
+        policy.setValidateIssuer(false);
+        policy.setExpectedAudience("expected-client");
+
+        AtomicBoolean routeReached = new AtomicBoolean(false);
+        KeycloakSecurityProcessor processor = new KeycloakSecurityProcessor(e 
-> routeReached.set(true), policy);
+
+        assertThrows(CamelAuthorizationException.class, () -> 
processor.process(bearer("x")));
+        assertFalse(routeReached.get(), "Route body must not be reached for a 
token missing the expected audience");
+    }
+
+    @Test
+    void testTokenWithMatchingAudienceAcceptedIntrospection() throws Exception 
{
+        KeycloakTokenIntrospector introspector = new KeycloakTokenIntrospector(
+                "http://localhost:8080";, "test-realm", "test-client", 
"test-secret", (TokenCache) null) {
+            @Override
+            public IntrospectionResult introspect(String token) {
+                return new IntrospectionResult(
+                        Map.of("active", true, "aud", 
List.of("expected-client", "other-client")));
+            }
+        };
+
+        KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy() {
+            @Override
+            public boolean isUseTokenIntrospection() {
+                return true;
+            }
+
+            @Override
+            public KeycloakTokenIntrospector getTokenIntrospector() {
+                return introspector;
+            }
+        };
+        policy.setServerUrl("http://localhost:8080";);
+        policy.setRealm("test-realm");
+        policy.setClientId("test-client");
+        policy.setClientSecret("test-secret");
+        policy.setValidateIssuer(false);
+        policy.setExpectedAudience("expected-client");
+
+        AtomicBoolean routeReached = new AtomicBoolean(false);
+        KeycloakSecurityProcessor processor = new KeycloakSecurityProcessor(e 
-> routeReached.set(true), policy);
+
+        processor.process(bearer("x"));
+        assertTrue(routeReached.get(), "Route body must be reached for a token 
with the expected audience");
+    }
+
+    @Test
+    void testTokenMissingOneOfMultipleExpectedAudiencesRejectedIntrospection() 
throws Exception {
+        KeycloakTokenIntrospector introspector = new KeycloakTokenIntrospector(
+                "http://localhost:8080";, "test-realm", "test-client", 
"test-secret", (TokenCache) null) {
+            @Override
+            public IntrospectionResult introspect(String token) {
+                // Token only has one of the two expected audiences
+                return new IntrospectionResult(Map.of("active", true, "aud", 
"expected-client"));
+            }
+        };
+
+        KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy() {
+            @Override
+            public boolean isUseTokenIntrospection() {
+                return true;
+            }
+
+            @Override
+            public KeycloakTokenIntrospector getTokenIntrospector() {
+                return introspector;
+            }
+        };
+        policy.setServerUrl("http://localhost:8080";);
+        policy.setRealm("test-realm");
+        policy.setClientId("test-client");
+        policy.setClientSecret("test-secret");
+        policy.setValidateIssuer(false);
+        policy.setExpectedAudience("expected-client,other-required-client");
+
+        AtomicBoolean routeReached = new AtomicBoolean(false);
+        KeycloakSecurityProcessor processor = new KeycloakSecurityProcessor(e 
-> routeReached.set(true), policy);
+
+        assertThrows(CamelAuthorizationException.class, () -> 
processor.process(bearer("x")));
+        assertFalse(routeReached.get(),
+                "Route body must not be reached when the token has only some 
of the expected audiences");
+    }
+
+    @Test
+    void 
testTokenMissingExpectedAudienceRejectedWithRequiredRolesIntrospection() throws 
Exception {
+        // Token would satisfy the required role, but is missing the expected 
audience: the audience check
+        // inside validateRoles() must still reject it before the role check 
ever runs.
+        KeycloakTokenIntrospector introspector = new KeycloakTokenIntrospector(
+                "http://localhost:8080";, "test-realm", "test-client", 
"test-secret", (TokenCache) null) {
+            @Override
+            public IntrospectionResult introspect(String token) {
+                return new IntrospectionResult(
+                        Map.of("active", true, "realm_access", Map.of("roles", 
List.of("admin"))));
+            }
+        };
+
+        KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy() {
+            @Override
+            public boolean isUseTokenIntrospection() {
+                return true;
+            }
+
+            @Override
+            public KeycloakTokenIntrospector getTokenIntrospector() {
+                return introspector;
+            }
+        };
+        policy.setServerUrl("http://localhost:8080";);
+        policy.setRealm("test-realm");
+        policy.setClientId("test-client");
+        policy.setClientSecret("test-secret");
+        policy.setValidateIssuer(false);
+        policy.setExpectedAudience("expected-client");
+        policy.setRequiredRoles("admin");
+
+        AtomicBoolean routeReached = new AtomicBoolean(false);
+        KeycloakSecurityProcessor processor = new KeycloakSecurityProcessor(e 
-> routeReached.set(true), policy);
+
+        assertThrows(CamelAuthorizationException.class, () -> 
processor.process(bearer("x")));
+        assertFalse(routeReached.get(),
+                "Route body must not be reached when the token has the 
required role but not the expected audience");
+    }
+
+    @Test
+    void 
testTokenMissingExpectedAudienceRejectedWithRequiredPermissionsIntrospection() 
throws Exception {
+        // Token would satisfy the required permission, but is missing the 
expected audience: the audience check
+        // inside validatePermissions() must still reject it before the 
permission check ever runs.
+        KeycloakTokenIntrospector introspector = new KeycloakTokenIntrospector(
+                "http://localhost:8080";, "test-realm", "test-client", 
"test-secret", (TokenCache) null) {
+            @Override
+            public IntrospectionResult introspect(String token) {
+                return new IntrospectionResult(Map.of("active", true, "scope", 
"read"));
+            }
+        };
+
+        KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy() {
+            @Override
+            public boolean isUseTokenIntrospection() {
+                return true;
+            }
+
+            @Override
+            public KeycloakTokenIntrospector getTokenIntrospector() {
+                return introspector;
+            }
+        };
+        policy.setServerUrl("http://localhost:8080";);
+        policy.setRealm("test-realm");
+        policy.setClientId("test-client");
+        policy.setClientSecret("test-secret");
+        policy.setValidateIssuer(false);
+        policy.setExpectedAudience("expected-client");
+        policy.setRequiredPermissions("read");
+
+        AtomicBoolean routeReached = new AtomicBoolean(false);
+        KeycloakSecurityProcessor processor = new KeycloakSecurityProcessor(e 
-> routeReached.set(true), policy);
+
+        assertThrows(CamelAuthorizationException.class, () -> 
processor.process(bearer("x")));
+        assertFalse(routeReached.get(),
+                "Route body must not be reached when the token has the 
required permission but not the expected audience");
+    }
 }


Reply via email to