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`,