This is an automated email from the ASF dual-hosted git repository.
oscerd 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 bb4922598c39 CAMEL-24319: camel-keycloak - add optional token type
(typ) and authorized party (azp) validation (#25259)
bb4922598c39 is described below
commit bb4922598c399f885107088a211e36107c727f2b
Author: Andrea Cosentino <[email protected]>
AuthorDate: Fri Jul 31 12:54:13 2026 +0200
CAMEL-24319: camel-keycloak - add optional token type (typ) and authorized
party (azp) validation (#25259)
Adds optional validation of the token type (typ) and authorized party (azp)
claims to camel-keycloak, following the same opt-in gating as the CAMEL-23875
audience validation. The checks are wired into all three enforcement paths —
authenticateToken(), validateRoles() and validatePermissions() — including the
permissions-only introspection path (useTokenIntrospection=true with only
requiredPermissions), where gnodet's review found they had initially been
missed and could be bypassed. D [...]
Co-authored-by: Claude Opus 4.8 <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
---
.../camel/catalog/docs/keycloak-security.adoc | 28 +++++++
.../src/main/docs/keycloak-security.adoc | 28 +++++++
.../keycloak/security/KeycloakSecurityHelper.java | 55 ++++++++++++
.../keycloak/security/KeycloakSecurityPolicy.java | 68 +++++++++++++++
.../security/KeycloakSecurityProcessor.java | 81 +++++++++++++++++-
.../security/KeycloakSecurityHelperTest.java | 97 ++++++++++++++++++++++
.../security/KeycloakSecurityProcessorTest.java | 79 ++++++++++++++++++
7 files changed, 435 insertions(+), 1 deletion(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-security.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-security.adoc
index d18ac18c1c56..1700046c008d 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-security.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-security.adoc
@@ -116,6 +116,34 @@ beans:
`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.
+=== Token Type and Authorized Party Validation
+
+By default, the security policy does not check the token's `typ` (token type)
or `azp` (authorized party) claim.
+Two optional settings tighten this:
+
+* `expectedTokenTypes` - a comma-separated allow-list of accepted `typ` values
(e.g., `"Bearer"`). When set, a
+token whose `typ` is not one of the configured values is rejected. This guards
against token-type confusion,
+for example an ID token or refresh token being presented where an access token
is expected.
+* `expectedAuthorizedParty` - the expected `azp` value (e.g., `"my-client"`).
When set, a token whose `azp` does
+not equal the configured value is rejected, ensuring the token was issued for
the expected client.
+
+Both checks apply to local JWT verification and to token introspection, and
both are disabled by default for
+backward compatibility.
+
+[source,java]
+----
+KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy(
+ "http://localhost:8080", "my-realm", "my-client", "client-secret");
+
+// Only accept access tokens (typ=Bearer) issued for "my-client"
+policy.setExpectedTokenTypes("Bearer");
+policy.setExpectedAuthorizedParty("my-client");
+
+from("direct:protected")
+ .policy(policy)
+ .to("mock:result");
+----
+
=== Role-based Authorization
[tabs]
diff --git a/components/camel-keycloak/src/main/docs/keycloak-security.adoc
b/components/camel-keycloak/src/main/docs/keycloak-security.adoc
index d18ac18c1c56..1700046c008d 100644
--- a/components/camel-keycloak/src/main/docs/keycloak-security.adoc
+++ b/components/camel-keycloak/src/main/docs/keycloak-security.adoc
@@ -116,6 +116,34 @@ beans:
`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.
+=== Token Type and Authorized Party Validation
+
+By default, the security policy does not check the token's `typ` (token type)
or `azp` (authorized party) claim.
+Two optional settings tighten this:
+
+* `expectedTokenTypes` - a comma-separated allow-list of accepted `typ` values
(e.g., `"Bearer"`). When set, a
+token whose `typ` is not one of the configured values is rejected. This guards
against token-type confusion,
+for example an ID token or refresh token being presented where an access token
is expected.
+* `expectedAuthorizedParty` - the expected `azp` value (e.g., `"my-client"`).
When set, a token whose `azp` does
+not equal the configured value is rejected, ensuring the token was issued for
the expected client.
+
+Both checks apply to local JWT verification and to token introspection, and
both are disabled by default for
+backward compatibility.
+
+[source,java]
+----
+KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy(
+ "http://localhost:8080", "my-realm", "my-client", "client-secret");
+
+// Only accept access tokens (typ=Bearer) issued for "my-client"
+policy.setExpectedTokenTypes("Bearer");
+policy.setExpectedAuthorizedParty("my-client");
+
+from("direct:protected")
+ .policy(policy)
+ .to("mock:result");
+----
+
=== Role-based Authorization
[tabs]
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 f529de805074..02277592b477 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
@@ -90,6 +90,36 @@ public final class KeycloakSecurityHelper {
public static AccessToken parseAndVerifyAccessToken(
String tokenString, PublicKey publicKey, String expectedIssuer,
List<String> expectedAudiences)
throws VerificationException {
+ return parseAndVerifyAccessToken(tokenString, publicKey,
expectedIssuer, expectedAudiences, null, null);
+ }
+
+ /**
+ * Parses and fully verifies an access token including signature, issuer
and, optionally, audience, token type
+ * ({@code typ}) and authorized party ({@code azp}) 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.
+ * @param expectedTokenTypes the accepted token types; when
non-empty, the token's {@code typ} claim must be
+ * one of them. Guards against token-type
confusion (e.g. an ID or refresh token
+ * presented where an access token is
expected). Pass null or an empty list to skip
+ * token-type validation.
+ * @param expectedAuthorizedParty the expected authorized party; when
non-empty, the token's {@code azp} claim must
+ * equal it. Ensures the token was issued
for the expected client. Pass null or an
+ * empty string to skip authorized-party
validation.
+ * @return the verified access token
+ * @throws VerificationException if verification fails (invalid
signature, wrong issuer, expired, missing/wrong
+ * audience, wrong token type, wrong
authorized party, etc.)
+ */
+ public static AccessToken parseAndVerifyAccessToken(
+ String tokenString, PublicKey publicKey, String expectedIssuer,
List<String> expectedAudiences,
+ List<String> expectedTokenTypes, String expectedAuthorizedParty)
+ throws VerificationException {
if (publicKey == null) {
throw new VerificationException("Public key is required for secure
token verification");
}
@@ -120,6 +150,31 @@ public final class KeycloakSecurityHelper {
String.format("Token issuer mismatch: expected '%s' but
got '%s'", expectedIssuer, actualIssuer));
}
+ // Optional token type (typ) allow-list — guards against token-type
confusion, e.g. an ID or refresh token
+ // being presented where an access token is expected.
+ if (expectedTokenTypes != null && !expectedTokenTypes.isEmpty()) {
+ String actualType = token.getType();
+ if (actualType == null ||
!expectedTokenTypes.contains(actualType)) {
+ LOG.error("SECURITY: Token type mismatch - expected one of {}
but got '{}'",
+ expectedTokenTypes, actualType);
+ throw new VerificationException(
+ String.format("Token type mismatch: expected one of %s
but got '%s'",
+ expectedTokenTypes, actualType));
+ }
+ }
+
+ // Optional authorized party (azp) check — ensures the token was
issued for the expected client.
+ if (expectedAuthorizedParty != null &&
!expectedAuthorizedParty.isEmpty()) {
+ String actualAzp = token.getIssuedFor();
+ if (!expectedAuthorizedParty.equals(actualAzp)) {
+ LOG.error("SECURITY: Token authorized party (azp) mismatch -
expected '{}' but got '{}'",
+ expectedAuthorizedParty, actualAzp);
+ throw new VerificationException(
+ String.format("Token authorized party mismatch:
expected '%s' but got '%s'",
+ expectedAuthorizedParty, actualAzp));
+ }
+ }
+
LOG.debug("Token successfully verified for issuer: {}",
expectedIssuer);
return token;
}
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 17781c45ec21..55d76ea17b9c 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
@@ -106,6 +106,21 @@ public class KeycloakSecurityPolicy implements
AuthorizationPolicy {
*/
private String expectedAudience;
+ /**
+ * Comma-separated list of accepted token types ({@code typ} claim). When
set, a token whose {@code typ} is not one
+ * of the configured values is rejected. This guards against token-type
confusion, e.g. an ID token or refresh token
+ * being presented where an access token is expected. Disabled by default
for backward compatibility. Example:
+ * "Bearer"
+ */
+ private String expectedTokenTypes;
+
+ /**
+ * Expected authorized party ({@code azp} claim). When set, a token whose
{@code azp} does not equal the configured
+ * value is rejected, ensuring the token was issued for the expected
client. Disabled by default for backward
+ * compatibility. Example: "my-client"
+ */
+ private String expectedAuthorizedParty;
+
public KeycloakSecurityPolicy() {
this.requiredRoles = "";
this.requiredPermissions = "";
@@ -470,4 +485,57 @@ public class KeycloakSecurityPolicy implements
AuthorizationPolicy {
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
+
+ /**
+ * Gets the accepted token type(s) as a comma-separated string.
+ *
+ * @return comma-separated token types (e.g., "Bearer"), or null if not
configured
+ */
+ public String getExpectedTokenTypes() {
+ return expectedTokenTypes;
+ }
+
+ /**
+ * Sets the accepted token type(s) as a comma-separated string. When set,
a token whose "typ" claim is not one of
+ * the configured values is rejected.
+ *
+ * @param expectedTokenTypes comma-separated token types (e.g., "Bearer")
+ */
+ public void setExpectedTokenTypes(String expectedTokenTypes) {
+ this.expectedTokenTypes = expectedTokenTypes;
+ }
+
+ /**
+ * Gets the accepted token types as a list.
+ *
+ * @return list of accepted token types, or an empty list if not configured
+ */
+ public List<String> getExpectedTokenTypesAsList() {
+ if (ObjectHelper.isEmpty(expectedTokenTypes)) {
+ return Collections.emptyList();
+ }
+ return Arrays.stream(expectedTokenTypes.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Gets the expected authorized party ({@code azp}).
+ *
+ * @return the expected authorized party, or null if not configured
+ */
+ public String getExpectedAuthorizedParty() {
+ return expectedAuthorizedParty;
+ }
+
+ /**
+ * Sets the expected authorized party ({@code azp}). When set, a token
whose "azp" claim does not equal the
+ * configured value is rejected.
+ *
+ * @param expectedAuthorizedParty the expected authorized party (e.g.,
"my-client")
+ */
+ public void setExpectedAuthorizedParty(String expectedAuthorizedParty) {
+ this.expectedAuthorizedParty = expectedAuthorizedParty;
+ }
}
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 943f3127079b..fb2fd7d34f3e 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
@@ -115,6 +115,14 @@ public class KeycloakSecurityProcessor extends
DelegateProcessor {
if (!policy.getExpectedAudienceAsList().isEmpty()) {
validateAudienceFromIntrospection(introspectionResult,
exchange);
}
+
+ if (!policy.getExpectedTokenTypesAsList().isEmpty()) {
+ validateTokenTypeFromIntrospection(introspectionResult,
exchange);
+ }
+
+ if (!ObjectHelper.isEmpty(policy.getExpectedAuthorizedParty())) {
+ validateAuthorizedPartyFromIntrospection(introspectionResult,
exchange);
+ }
} else {
parseAndVerifyToken(accessToken, exchange);
}
@@ -284,6 +292,15 @@ public class KeycloakSecurityProcessor extends
DelegateProcessor {
validateAudienceFromIntrospection(introspectionResult,
exchange);
}
+ // Validate token type / authorized party from introspection
result if configured
+ if (!policy.getExpectedTokenTypesAsList().isEmpty()) {
+ validateTokenTypeFromIntrospection(introspectionResult,
exchange);
+ }
+
+ if
(!ObjectHelper.isEmpty(policy.getExpectedAuthorizedParty())) {
+
validateAuthorizedPartyFromIntrospection(introspectionResult, exchange);
+ }
+
userRoles =
KeycloakSecurityHelper.extractRolesFromIntrospection(
introspectionResult, policy.getRealm(),
policy.getClientId());
} else {
@@ -340,7 +357,8 @@ public class KeycloakSecurityProcessor extends
DelegateProcessor {
if (publicKey != null) {
try {
return KeycloakSecurityHelper.parseAndVerifyAccessToken(
- accessToken, publicKey, expectedIssuer,
policy.getExpectedAudienceAsList());
+ accessToken, publicKey, expectedIssuer,
policy.getExpectedAudienceAsList(),
+ policy.getExpectedTokenTypesAsList(),
policy.getExpectedAuthorizedParty());
} catch (VerificationException e) {
LOG.error("Token verification failed: {}", e.getMessage());
throw new CamelAuthorizationException("Token verification
failed: " + e.getMessage(), exchange, e);
@@ -416,6 +434,57 @@ public class KeycloakSecurityProcessor extends
DelegateProcessor {
LOG.debug("Audience validation from introspection successful: {}",
expectedAudiences);
}
+ /**
+ * Validates the token type ({@code typ}) from an introspection result.
When token-type validation is configured, a
+ * token whose {@code typ} is missing or not among the accepted values is
rejected — guarding against token-type
+ * confusion (e.g. an ID or refresh token presented where an access token
is expected).
+ */
+ private void validateTokenTypeFromIntrospection(
+ KeycloakTokenIntrospector.IntrospectionResult introspectionResult,
Exchange exchange)
+ throws CamelAuthorizationException {
+ List<String> expectedTokenTypes = policy.getExpectedTokenTypesAsList();
+ // Use the JWT "typ" claim (token category, e.g. Bearer/Refresh/ID),
which Keycloak forwards on its
+ // introspection response — not the RFC 7662 "token_type" field, which
is the OAuth token type ("Bearer")
+ // and does not distinguish access from refresh/ID tokens, i.e. it
cannot express what this check validates.
+ Object typeClaim = introspectionResult.getClaim("typ");
+ String actualType = typeClaim instanceof String s ? s : null;
+
+ if (actualType == null || !expectedTokenTypes.contains(actualType)) {
+ LOG.error("SECURITY: Token type mismatch from introspection -
expected one of {} but got '{}'",
+ expectedTokenTypes, actualType);
+ throw new CamelAuthorizationException(
+ String.format("Token type mismatch: expected one of %s but
got '%s'",
+ expectedTokenTypes, actualType),
+ exchange);
+ }
+
+ LOG.debug("Token type validation from introspection successful: {}",
expectedTokenTypes);
+ }
+
+ /**
+ * Validates the authorized party ({@code azp}) from an introspection
result. When authorized-party validation is
+ * configured, a token whose {@code azp} does not equal the expected value
is rejected — ensuring the token was
+ * issued for the expected client.
+ */
+ private void validateAuthorizedPartyFromIntrospection(
+ KeycloakTokenIntrospector.IntrospectionResult introspectionResult,
Exchange exchange)
+ throws CamelAuthorizationException {
+ String expectedAuthorizedParty = policy.getExpectedAuthorizedParty();
+ Object azpClaim = introspectionResult.getClaim("azp");
+ String actualAzp = azpClaim instanceof String s ? s : null;
+
+ if (!expectedAuthorizedParty.equals(actualAzp)) {
+ LOG.error("SECURITY: Token authorized party (azp) mismatch from
introspection - expected '{}' but got '{}'",
+ expectedAuthorizedParty, actualAzp);
+ throw new CamelAuthorizationException(
+ String.format("Token authorized party mismatch: expected
'%s' but got '%s'",
+ expectedAuthorizedParty, actualAzp),
+ exchange);
+ }
+
+ LOG.debug("Authorized party validation from introspection successful:
{}", expectedAuthorizedParty);
+ }
+
private void validatePermissions(String accessToken, Exchange exchange)
throws Exception {
try {
Set<String> userPermissions;
@@ -440,6 +509,16 @@ public class KeycloakSecurityProcessor extends
DelegateProcessor {
validateAudienceFromIntrospection(introspectionResult,
exchange);
}
+ // Validate token type and authorized party too, otherwise
these checks could be bypassed by
+ // configuring only permissions (which skips
authenticateToken/validateRoles) with introspection.
+ if (!policy.getExpectedTokenTypesAsList().isEmpty()) {
+ validateTokenTypeFromIntrospection(introspectionResult,
exchange);
+ }
+
+ if
(!ObjectHelper.isEmpty(policy.getExpectedAuthorizedParty())) {
+
validateAuthorizedPartyFromIntrospection(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 90639e8374d1..539417472e9b 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
@@ -224,6 +224,103 @@ public class KeycloakSecurityHelperTest {
assertEquals(expectedIssuer, verified.getIssuer());
}
+ @Test
+ void testParseAndVerifyAccessTokenAcceptsMatchingTokenType() 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.type("Bearer");
+
+ String signed = new JWSBuilder()
+ .type("JWT")
+ .jsonContent(token)
+ .rsa256(keyPair.getPrivate());
+
+ AccessToken verified =
KeycloakSecurityHelper.parseAndVerifyAccessToken(
+ signed, keyPair.getPublic(), expectedIssuer, null,
List.of("Bearer"), null);
+ assertEquals("user-123", verified.getSubject());
+ }
+
+ @Test
+ void testParseAndVerifyAccessTokenRejectsWrongTokenType() throws Exception
{
+ String expectedIssuer = "http://localhost:8080/realms/test";
+
+ KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
+ keyGen.initialize(2048);
+ KeyPair keyPair = keyGen.generateKeyPair();
+
+ // An ID token presented where an access token (typ=Bearer) is
expected.
+ AccessToken token = new AccessToken();
+ token.issuer(expectedIssuer);
+ token.subject("user-123");
+ token.exp(System.currentTimeMillis() / 1000 + 3600);
+ token.type("ID");
+
+ String signed = new JWSBuilder()
+ .type("JWT")
+ .jsonContent(token)
+ .rsa256(keyPair.getPrivate());
+
+ assertThrows(VerificationException.class,
+ () -> KeycloakSecurityHelper.parseAndVerifyAccessToken(
+ signed, keyPair.getPublic(), expectedIssuer, null,
List.of("Bearer"), null));
+ }
+
+ @Test
+ void testParseAndVerifyAccessTokenAcceptsMatchingAuthorizedParty() 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.issuedFor("my-client");
+
+ String signed = new JWSBuilder()
+ .type("JWT")
+ .jsonContent(token)
+ .rsa256(keyPair.getPrivate());
+
+ AccessToken verified =
KeycloakSecurityHelper.parseAndVerifyAccessToken(
+ signed, keyPair.getPublic(), expectedIssuer, null, null,
"my-client");
+ assertEquals("user-123", verified.getSubject());
+ }
+
+ @Test
+ void testParseAndVerifyAccessTokenRejectsWrongAuthorizedParty() 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.issuedFor("other-client");
+
+ String signed = new JWSBuilder()
+ .type("JWT")
+ .jsonContent(token)
+ .rsa256(keyPair.getPrivate());
+
+ assertThrows(VerificationException.class,
+ () -> KeycloakSecurityHelper.parseAndVerifyAccessToken(
+ signed, keyPair.getPublic(), expectedIssuer, null,
null, "my-client"));
+ }
+
@Test
void testExtractKeyIdReturnsKidFromHeader() throws Exception {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
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 c3c1c2f8f269..c13a39723e0b 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
@@ -372,4 +372,83 @@ class KeycloakSecurityProcessorTest {
assertFalse(routeReached.get(),
"Route body must not be reached when the token has the
required permission but not the expected audience");
}
+
+ @Test
+ void testTokenWrongTokenTypeRejectedWithRequiredPermissionsIntrospection()
throws Exception {
+ // Token satisfies the required permission but carries the wrong token
type: validatePermissions() must
+ // still run the token-type check on the introspection path, otherwise
it could be bypassed with a
+ // permissions-only configuration.
+ 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", "typ", "Refresh"));
+ }
+ };
+
+ 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.setExpectedTokenTypes("Bearer");
+ 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 the wrong token type");
+ }
+
+ @Test
+ void
testTokenWrongAuthorizedPartyRejectedWithRequiredPermissionsIntrospection()
throws Exception {
+ // Token satisfies the required permission but carries the wrong
authorized party (azp): validatePermissions()
+ // must still run the azp check on the introspection path.
+ 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", "azp", "attacker-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.setExpectedAuthorizedParty("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 the wrong authorized party");
+ }
}