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) {
+ }
+}