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");
+ }
}