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

davsclaus pushed a commit to branch camel-4.22.x
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/camel-4.22.x by this push:
     new b2d9957bcbcf [backport camel-4.22.x] CAMEL-24872: camel-oauth: Align 
token caching with effective profile configuration (#27779)
b2d9957bcbcf is described below

commit b2d9957bcbcf87bd5e59992fec45231876c645ed
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Oct 14 21:21:01 2026 +0200

    [backport camel-4.22.x] CAMEL-24872: camel-oauth: Align token caching with 
effective profile configuration (#27779)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Co-authored-by: OpenAI Codex <[email protected]>
---
 .../org/apache/camel/catalog/docs/oauth.adoc       |   2 +
 components/camel-oauth/src/main/docs/oauth.adoc    |   2 +
 .../oauth/OAuthClientCredentialsTokenResolver.java |  29 +++-
 .../OAuthClientCredentialsTokenResolverTest.java   | 176 +++++++++++++++++++++
 4 files changed, 205 insertions(+), 4 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/oauth.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/oauth.adoc
index 94a70638f373..69bb9ded745e 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/oauth.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/oauth.adoc
@@ -111,6 +111,8 @@ Properties are resolved from `camel.oauth.<profileName>.*`:
 |`camel.oauth.<profile>.cached-tokens-expiration-margin-seconds` |No |Safety 
margin subtracted from token expiry to refresh early. Default: `5`.
 |===
 
+When token caching is enabled, profiles with the same token endpoint, client 
ID, client secret and requested scope can reuse a token across resolver 
instances. Different credentials or scopes use separate cache entries. Null, 
empty and whitespace-only scopes all omit the scope parameter and share the 
same cache identity; other scope values are matched exactly. Setting 
`cache-tokens=false` requests a fresh token on each resolution.
+
 ==== Example: Multiple Identity Providers
 
 [source,properties]
diff --git a/components/camel-oauth/src/main/docs/oauth.adoc 
b/components/camel-oauth/src/main/docs/oauth.adoc
index 94a70638f373..69bb9ded745e 100644
--- a/components/camel-oauth/src/main/docs/oauth.adoc
+++ b/components/camel-oauth/src/main/docs/oauth.adoc
@@ -111,6 +111,8 @@ Properties are resolved from `camel.oauth.<profileName>.*`:
 |`camel.oauth.<profile>.cached-tokens-expiration-margin-seconds` |No |Safety 
margin subtracted from token expiry to refresh early. Default: `5`.
 |===
 
+When token caching is enabled, profiles with the same token endpoint, client 
ID, client secret and requested scope can reuse a token across resolver 
instances. Different credentials or scopes use separate cache entries. Null, 
empty and whitespace-only scopes all omit the scope parameter and share the 
same cache identity; other scope values are matched exactly. Setting 
`cache-tokens=false` requests a fresh token on each resolution.
+
 ==== Example: Multiple Identity Providers
 
 [source,properties]
diff --git 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthClientCredentialsTokenResolver.java
 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthClientCredentialsTokenResolver.java
index 74ce15db46ce..9c4c4508ccf6 100644
--- 
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthClientCredentialsTokenResolver.java
+++ 
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/OAuthClientCredentialsTokenResolver.java
@@ -16,6 +16,10 @@
  */
 package org.apache.camel.oauth;
 
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
 
@@ -27,8 +31,8 @@ import org.slf4j.LoggerFactory;
 /**
  * Resolves OAuth 2.0 bearer tokens using the client_credentials grant with 
thread-safe caching.
  * <p/>
- * Tokens are cached per (tokenEndpoint, clientId) pair using {@link 
UserProfile} for expiry tracking. This class
- * contains no Processor or Exchange dependencies — it is a pure token 
resolver.
+ * Tokens are cached per token endpoint, client credentials and requested 
scope using {@link UserProfile} for expiry
+ * tracking. This class contains no Processor or Exchange dependencies — it is 
a pure token resolver.
  */
 public class OAuthClientCredentialsTokenResolver {
 
@@ -46,7 +50,11 @@ public class OAuthClientCredentialsTokenResolver {
         UserProfile profile;
 
         if (config.isCacheTokens()) {
-            TokenCacheKey cacheKey = new 
TokenCacheKey(config.getTokenEndpoint(), config.getClientId());
+            String scope = config.getScope();
+            // Null and blank scopes are both omitted from the token request.
+            TokenCacheKey cacheKey = new TokenCacheKey(
+                    config.getTokenEndpoint(), config.getClientId(),
+                    secretFingerprint(config.getClientSecret()), scope == null 
|| scope.isBlank() ? null : scope);
             long margin = config.getCachedTokensExpirationMarginSeconds();
             profile = TOKEN_CACHE.compute(cacheKey, (key, existing) -> {
                 if (existing != null && existing.ttl() > margin) {
@@ -74,6 +82,19 @@ public class OAuthClientCredentialsTokenResolver {
         return profile;
     }
 
-    record TokenCacheKey(String tokenEndpoint, String clientId) {
+    private static String secretFingerprint(String clientSecret) {
+        if (clientSecret == null) {
+            return null;
+        }
+        try {
+            // Distinguish credentials without retaining the raw secret in the 
static cache.
+            return 
HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
+                    .digest(clientSecret.getBytes(StandardCharsets.UTF_8)));
+        } catch (NoSuchAlgorithmException e) {
+            throw new IllegalStateException("SHA-256 is unavailable", e);
+        }
+    }
+
+    record TokenCacheKey(String tokenEndpoint, String clientId, String 
clientSecretFingerprint, String scope) {
     }
 }
diff --git 
a/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthClientCredentialsTokenResolverTest.java
 
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthClientCredentialsTokenResolverTest.java
new file mode 100644
index 000000000000..a8c9fde18ba3
--- /dev/null
+++ 
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/OAuthClientCredentialsTokenResolverTest.java
@@ -0,0 +1,176 @@
+/*
+ * 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.oauth;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.stream.Stream;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.camel.spi.OAuthClientConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class OAuthClientCredentialsTokenResolverTest {
+
+    private final List<TokenRequest> requests = new CopyOnWriteArrayList<>();
+    private HttpServer server;
+    private String endpoint;
+
+    @BeforeEach
+    void startServer() throws IOException {
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        // A unique path prevents static cache entries from leaking across 
tests if a port is reused.
+        String path = "/token/" + UUID.randomUUID();
+        server.createContext(path, this::handleTokenRequest);
+        server.start();
+        endpoint = "http://127.0.0.1:"; + server.getAddress().getPort() + path;
+    }
+
+    @AfterEach
+    void stopServer() {
+        if (server != null) {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    void identicalConfigurationSharesTokenAcrossResolvers() {
+        assertEquals("token-1", new 
OAuthClientCredentialsTokenResolver().resolveToken(config("read")));
+        assertEquals("token-1", new 
OAuthClientCredentialsTokenResolver().resolveToken(config("read")));
+        assertEquals(1, requests.size());
+        assertEquals("read", requests.get(0).scope());
+    }
+
+    @ParameterizedTest
+    @MethodSource("differentScopes")
+    void differentScopesUseSeparateTokens(String firstScope, String 
secondScope) {
+        assertSeparateTokens(config(firstScope), config(secondScope));
+        assertEquals(firstScope, requests.get(0).scope());
+        assertEquals(secondScope, requests.get(1).scope());
+    }
+
+    static Stream<Arguments> differentScopes() {
+        return Stream.of(
+                Arguments.of("read", "write"),
+                Arguments.of("write", "read"),
+                Arguments.of(null, "read"),
+                Arguments.of("read", null),
+                Arguments.of("read", "READ"),
+                Arguments.of("read write", "write read"));
+    }
+
+    @Test
+    void differentClientsUseSeparateTokens() {
+        assertSeparateTokens(config("read"), 
config("read").setClientId("other-client"));
+        assertEquals("client:secret", requests.get(0).credentials());
+        assertEquals("other-client:secret", requests.get(1).credentials());
+    }
+
+    @Test
+    void differentEndpointsUseSeparateTokens() {
+        assertSeparateTokens(config("read"), 
config("read").setTokenEndpoint(endpoint + "/other"));
+        assertEquals(endpoint, requests.get(0).endpoint());
+        assertEquals(endpoint + "/other", requests.get(1).endpoint());
+    }
+
+    @Test
+    void differentSecretsUseSeparateTokens() {
+        assertSeparateTokens(config("read"), 
config("read").setClientSecret("other-secret"));
+        assertEquals("client:secret", requests.get(0).credentials());
+        assertEquals("client:other-secret", requests.get(1).credentials());
+    }
+
+    @Test
+    void disabledCacheAlwaysAcquiresTokenWithoutReplacingCachedToken() {
+        OAuthClientCredentialsTokenResolver resolver = new 
OAuthClientCredentialsTokenResolver();
+        assertEquals("token-1", resolver.resolveToken(config("read")));
+        OAuthClientConfig uncached = config("read").setCacheTokens(false);
+        assertEquals("token-2", resolver.resolveToken(uncached));
+        assertEquals("token-3", resolver.resolveToken(uncached));
+        assertEquals("token-1", resolver.resolveToken(config("read")));
+        assertEquals(3, requests.size());
+    }
+
+    @Test
+    void omittedScopesShareToken() {
+        OAuthClientCredentialsTokenResolver resolver = new 
OAuthClientCredentialsTokenResolver();
+        assertEquals("token-1", resolver.resolveToken(config(null)));
+        assertEquals("token-1", resolver.resolveToken(config("")));
+        assertEquals("token-1", resolver.resolveToken(config(" \t")));
+        assertEquals(1, requests.size());
+        assertNull(requests.get(0).scope());
+    }
+
+    private OAuthClientConfig config(String scope) {
+        return new 
OAuthClientConfig().setTokenEndpoint(endpoint).setClientId("client")
+                .setClientSecret("secret").setScope(scope);
+    }
+
+    private void assertSeparateTokens(OAuthClientConfig first, 
OAuthClientConfig second) {
+        OAuthClientCredentialsTokenResolver firstResolver = new 
OAuthClientCredentialsTokenResolver();
+        OAuthClientCredentialsTokenResolver secondResolver = new 
OAuthClientCredentialsTokenResolver();
+        assertEquals("token-1", firstResolver.resolveToken(first));
+        assertEquals("token-2", secondResolver.resolveToken(second));
+        assertEquals("token-1", secondResolver.resolveToken(first));
+        assertEquals("token-2", firstResolver.resolveToken(second));
+        assertEquals(2, requests.size());
+    }
+
+    private void handleTokenRequest(HttpExchange exchange) throws IOException {
+        Map<String, String> form = new HashMap<>();
+        String body = new String(exchange.getRequestBody().readAllBytes(), 
StandardCharsets.UTF_8);
+        for (String pair : body.split("&")) {
+            String[] parts = pair.split("=", 2);
+            form.put(URLDecoder.decode(parts[0], StandardCharsets.UTF_8),
+                    parts.length == 2 ? URLDecoder.decode(parts[1], 
StandardCharsets.UTF_8) : "");
+        }
+        String authorization = 
exchange.getRequestHeaders().getFirst("Authorization");
+        String credentials = new String(
+                Base64.getDecoder().decode(authorization.substring("Basic 
".length())),
+                StandardCharsets.UTF_8);
+        requests.add(new TokenRequest(
+                "http://127.0.0.1:"; + server.getAddress().getPort() + 
exchange.getRequestURI(),
+                credentials, form.get("scope")));
+        byte[] response = ("{\"access_token\":\"token-" + requests.size() + 
"\",\"expires_in\":3600}")
+                .getBytes(StandardCharsets.UTF_8);
+        exchange.getResponseHeaders().set("Content-Type", "application/json");
+        exchange.sendResponseHeaders(200, response.length);
+        try (var outputStream = exchange.getResponseBody()) {
+            outputStream.write(response);
+        }
+    }
+
+    private record TokenRequest(String endpoint, String credentials, String 
scope) {
+    }
+}

Reply via email to