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 5e896b2df24b CAMEL-24872: camel-oauth - align token caching with 
effective profile configuration
5e896b2df24b is described below

commit 5e896b2df24bf80ad4a78be1f8a708f71d91aa16
Author: Andrea Cosentino <[email protected]>
AuthorDate: Mon Sep 21 21:06:21 2026 +0200

    CAMEL-24872: camel-oauth - align token caching with effective profile 
configuration
    
    The client-credentials token cache is now keyed by the effective token
    request configuration: equivalent configurations keep reusing tokens
    across resolver instances, while differences in credentials or the
    requested scope produce separate cache entries. Omitted scopes behave
    as before, and disabling caching still requests a fresh token.
    
    Adds 12 regression cases, updates the component documentation and
    the 4.23 upgrade guide.
    
    Closes #26681
    
    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 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |   4 +
 5 files changed, 209 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 2042d3283f4f..329afa8e0542 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 2042d3283f4f..329afa8e0542 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) {
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index aa72b55e2601..64b6b96896e2 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -13,6 +13,10 @@ See the xref:camel-upgrade-recipes-tool.adoc[documentation] 
page for details.
 
 == Upgrading Camel 4.22 to 4.23
 
+=== camel-oauth
+
+OAuth client credentials token caching now distinguishes profiles by client 
secret and requested scope, in addition to token endpoint and client ID. 
Profiles with different credentials or scopes request separate tokens instead 
of reusing the same cached token. Applications using such profiles may make 
additional token requests after upgrading.
+
 === Circuit Breaker EIP
 
 The exchange property `CamelCircuitBreakerResponseRejected` is now also set 
inside the `onFallback`,

Reply via email to