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 f4c1902ddac4 CAMEL-23768: camel-keycloak - select the JWKS
verification key by the token kid (#24527)
f4c1902ddac4 is described below
commit f4c1902ddac4680b1e5a9374ca135bef11f7b583
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Jul 8 23:03:59 2026 +0200
CAMEL-23768: camel-keycloak - select the JWKS verification key by the token
kid (#24527)
KeycloakPublicKeyResolver ignored the JWT header kid and returned the first
key in
the JWKS, and KeycloakSecurityProcessor never passed the kid through
(getPublicKey(null)). During key rotation (multiple keys present) this
could pick
the wrong key and reject an otherwise-valid token. The token is still
cryptographically verified against a real key, so this is a
correctness/availability
matter, not a bypass.
The processor now extracts the kid from the token header
(KeycloakSecurityHelper
.extractKeyId via Keycloak's JWSInput) and passes it to the resolver.
getPublicKey
forces a single refresh when the kid is unknown (to pick up rotated keys)
and fails
closed via a new selectKey helper when a kid is given but no key matches;
the
first-key fallback is kept only when the token carries no kid.
Adds KeycloakPublicKeyResolverTest (matching kid / fail-closed on unknown
kid /
first-key fallback for null kid) and extractKeyId coverage in
KeycloakSecurityHelperTest.
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
.../security/KeycloakPublicKeyResolver.java | 33 ++++++--
.../keycloak/security/KeycloakSecurityHelper.java | 17 ++++
.../security/KeycloakSecurityProcessor.java | 4 +-
.../security/KeycloakPublicKeyResolverTest.java | 91 ++++++++++++++++++++++
.../security/KeycloakSecurityHelperTest.java | 23 ++++++
5 files changed, 162 insertions(+), 6 deletions(-)
diff --git
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakPublicKeyResolver.java
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakPublicKeyResolver.java
index d57380e1b75f..8381685047b9 100644
---
a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakPublicKeyResolver.java
+++
b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/security/KeycloakPublicKeyResolver.java
@@ -57,7 +57,8 @@ public class KeycloakPublicKeyResolver {
/**
* Gets the public key for verifying JWT signatures. Keys are cached and
refreshed periodically.
*
- * @param kid the key ID from the JWT header (optional, uses
first key if null)
+ * @param kid the key ID from the JWT header; the matching key is
selected, and resolution fails closed
+ * when a kid is given but no key matches (a {@code
null} kid falls back to the first key)
* @return the public key
* @throws IOException if fetching keys fails
*/
@@ -68,11 +69,33 @@ public class KeycloakPublicKeyResolver {
refreshKeys();
}
- if (kid != null && keyCache.containsKey(kid)) {
- return keyCache.get(kid);
+ // If the kid is not known yet, force a refresh once to pick up
rotated keys
+ if (kid != null && !keyCache.containsKey(kid)) {
+ refreshKeys();
+ }
+
+ return selectKey(kid);
+ }
+
+ /**
+ * Selects the cached key matching the given kid. Fails closed (throws)
when a kid is provided but no key matches,
+ * so a token is never verified against the wrong key during key rotation.
Falls back to the first available key
+ * only when the token carries no kid.
+ *
+ * @param kid the key ID from the JWT header, or {@code null}
+ * @return the matching public key
+ * @throws IOException if no matching key (or, for a {@code null} kid, no
key at all) is available
+ */
+ PublicKey selectKey(String kid) throws IOException {
+ if (kid != null) {
+ PublicKey key = keyCache.get(kid);
+ if (key == null) {
+ throw new IOException("No public key found in Keycloak JWKS
for kid: " + kid);
+ }
+ return key;
}
- // If no kid specified or not found, return the first available key
+ // No kid in the token header - fall back to the first available key
if (!keyCache.isEmpty()) {
return keyCache.values().iterator().next();
}
@@ -105,7 +128,7 @@ public class KeycloakPublicKeyResolver {
}
@SuppressWarnings("unchecked")
- private void parseJwks(String jwksJson) throws IOException {
+ void parseJwks(String jwksJson) throws IOException {
Map<String, Object> jwks = OBJECT_MAPPER.readValue(jwksJson,
Map.class);
List<Map<String, Object>> keys = (List<Map<String, Object>>)
jwks.get("keys");
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 5f05ce04373e..f529de805074 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
@@ -29,6 +29,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.keycloak.TokenVerifier;
import org.keycloak.common.VerificationException;
+import org.keycloak.jose.jws.JWSInput;
import org.keycloak.representations.AccessToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -41,6 +42,22 @@ public final class KeycloakSecurityHelper {
// Utility class
}
+ /**
+ * Extracts the key ID ({@code kid}) from the JWT header, so the matching
JWKS key can be selected.
+ *
+ * @param tokenString the serialized JWT
+ * @return the {@code kid} from the JWS header, or {@code
null} if it is absent or the token cannot be
+ * parsed
+ */
+ public static String extractKeyId(String tokenString) {
+ try {
+ return new JWSInput(tokenString).getHeader().getKeyId();
+ } catch (Exception e) {
+ LOG.debug("Could not extract kid from token header: {}",
e.getMessage());
+ return null;
+ }
+ }
+
/**
* Parses and fully verifies an access token including signature and
issuer validation. This is the recommended
* method for secure token validation.
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 19cb94f70442..943f3127079b 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
@@ -325,7 +325,9 @@ public class KeycloakSecurityProcessor extends
DelegateProcessor {
// Get public key from auto-fetch resolver or manual configuration
if (policy.isAutoFetchPublicKey() && resolver != null) {
try {
- publicKey = resolver.getPublicKey(null);
+ // select the JWKS key matching the token's kid (handles key
rotation)
+ String kid = KeycloakSecurityHelper.extractKeyId(accessToken);
+ publicKey = resolver.getPublicKey(kid);
} catch (IOException e) {
LOG.error("Failed to fetch public key from JWKS endpoint: {}",
e.getMessage());
throw new CamelAuthorizationException("Failed to fetch public
key for token verification", exchange, e);
diff --git
a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakPublicKeyResolverTest.java
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakPublicKeyResolverTest.java
new file mode 100644
index 000000000000..0976b2e8cd93
--- /dev/null
+++
b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/security/KeycloakPublicKeyResolverTest.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.keycloak.security;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.security.KeyPairGenerator;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Base64;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class KeycloakPublicKeyResolverTest {
+
+ private static RSAPublicKey generateKey() throws Exception {
+ KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
+ keyGen.initialize(2048);
+ return (RSAPublicKey) keyGen.generateKeyPair().getPublic();
+ }
+
+ // base64url (no padding) of the unsigned big-endian value, as required
for a JWK n/e component
+ private static String b64url(BigInteger value) {
+ byte[] bytes = value.toByteArray();
+ if (bytes.length > 1 && bytes[0] == 0) {
+ byte[] unsigned = new byte[bytes.length - 1];
+ System.arraycopy(bytes, 1, unsigned, 0, unsigned.length);
+ bytes = unsigned;
+ }
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+ private static String jwksKey(String kid, RSAPublicKey key) {
+ return
String.format("{\"kty\":\"RSA\",\"use\":\"sig\",\"kid\":\"%s\",\"n\":\"%s\",\"e\":\"%s\"}",
+ kid, b64url(key.getModulus()),
b64url(key.getPublicExponent()));
+ }
+
+ @Test
+ void selectsKeyMatchingTheKid() throws Exception {
+ RSAPublicKey key1 = generateKey();
+ RSAPublicKey key2 = generateKey();
+ String jwks = "{\"keys\":[" + jwksKey("key-1", key1) + "," +
jwksKey("key-2", key2) + "]}";
+
+ KeycloakPublicKeyResolver resolver = new
KeycloakPublicKeyResolver("http://localhost:8080", "test");
+ resolver.parseJwks(jwks);
+
+ // the key selected for a kid must be that kid's key, not merely the
first one
+ assertEquals(key2.getModulus(), ((RSAPublicKey)
resolver.selectKey("key-2")).getModulus());
+ assertEquals(key1.getModulus(), ((RSAPublicKey)
resolver.selectKey("key-1")).getModulus());
+ }
+
+ @Test
+ void failsClosedWhenKidNotFound() throws Exception {
+ RSAPublicKey key1 = generateKey();
+ String jwks = "{\"keys\":[" + jwksKey("key-1", key1) + "]}";
+
+ KeycloakPublicKeyResolver resolver = new
KeycloakPublicKeyResolver("http://localhost:8080", "test");
+ resolver.parseJwks(jwks);
+
+ // a token whose kid is not in the JWKS must not be verified against a
different (first) key
+ assertThrows(IOException.class, () ->
resolver.selectKey("unknown-kid"));
+ }
+
+ @Test
+ void fallsBackToFirstKeyWhenNoKid() throws Exception {
+ RSAPublicKey key1 = generateKey();
+ String jwks = "{\"keys\":[" + jwksKey("key-1", key1) + "]}";
+
+ KeycloakPublicKeyResolver resolver = new
KeycloakPublicKeyResolver("http://localhost:8080", "test");
+ resolver.parseJwks(jwks);
+
+ assertNotNull(resolver.selectKey(null));
+ }
+}
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 5939b9fbd134..90639e8374d1 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,29 @@ public class KeycloakSecurityHelperTest {
assertEquals(expectedIssuer, verified.getIssuer());
}
+ @Test
+ void testExtractKeyIdReturnsKidFromHeader() throws Exception {
+ KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
+ keyGen.initialize(2048);
+ KeyPair keyPair = keyGen.generateKeyPair();
+
+ AccessToken token = new AccessToken();
+ token.subject("user-123");
+
+ String signed = new JWSBuilder()
+ .type("JWT")
+ .kid("test-key-id")
+ .jsonContent(token)
+ .rsa256(keyPair.getPrivate());
+
+ assertEquals("test-key-id",
KeycloakSecurityHelper.extractKeyId(signed));
+ }
+
+ @Test
+ void testExtractKeyIdReturnsNullForMalformedToken() {
+ assertNull(KeycloakSecurityHelper.extractKeyId("not-a-jwt"));
+ }
+
@Test
void testParseAndVerifyAccessTokenAcceptsMatchingAudience() throws
Exception {
String expectedIssuer = "http://localhost:8080/realms/test";