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";;

Reply via email to