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 d1a4478a70c1 CAMEL-26404: camel-oauth - validate JWT audience for
access tokens on servlet backend
d1a4478a70c1 is described below
commit d1a4478a70c18ab5feaed0692bb3c07fa70e3323
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Oct 29 17:39:55 2026 +0100
CAMEL-26404: camel-oauth - validate JWT audience for access tokens on
servlet backend
Enforce audience validation for access tokens in UserProfile against
configured expected audiences in JWTOptions, falling back to client ID. Reject
tokens missing aud or presenting a mismatched audience.
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../java/org/apache/camel/oauth/UserProfile.java | 47 +++++--
.../org/apache/camel/oauth/UserProfileTest.java | 136 ++++++++++++++++++++-
.../jakarta/ServletOAuthTokenCredentialsTest.java | 81 +++++++++++-
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 11 ++
4 files changed, 260 insertions(+), 15 deletions(-)
diff --git
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/UserProfile.java
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/UserProfile.java
index 43ca1400d2d3..a482fe58a548 100644
---
a/components/camel-oauth/src/main/java/org/apache/camel/oauth/UserProfile.java
+++
b/components/camel-oauth/src/main/java/org/apache/camel/oauth/UserProfile.java
@@ -18,6 +18,7 @@ package org.apache.camel.oauth;
import java.security.interfaces.RSAPublicKey;
import java.util.ArrayList;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -211,16 +212,33 @@ public class UserProfile {
}
}
String clientId = config.getClientId();
- if (idToken && !targetAudience.contains(clientId)) {
- throw new OAuthException("Invalid JWT audience. Expected to
contain: " + clientId);
- }
- if (idToken && tokenJwt.has("azp")) {
- String authorizedParty = tokenJwt.get("azp").getAsString();
- if (!authorizedParty.equals(clientId)) {
- throw new OAuthException("Invalid authorized party !=
config.clientID");
+ if (idToken) {
+ if (!targetAudience.contains(clientId)) {
+ throw new OAuthException("Invalid JWT audience. Expected
to contain: " + clientId);
+ }
+ if (tokenJwt.has("azp")) {
+ String authorizedParty = tokenJwt.get("azp").getAsString();
+ if (!authorizedParty.equals(clientId)) {
+ throw new OAuthException("Invalid authorized party !=
config.clientID");
+ }
+ if (!targetAudience.contains(authorizedParty)) {
+ throw new OAuthException("ID Token audience, doesn't
contain authorized party");
+ }
+ }
+ } else {
+ List<String> expectedAudiences =
resolveExpectedAudiences(config, jwtOptions);
+ if (expectedAudiences.isEmpty()) {
+ throw new OAuthException("Token validation requires
expected audience; none configured");
+ }
+ boolean matched = false;
+ for (String expected : expectedAudiences) {
+ if (targetAudience.contains(expected)) {
+ matched = true;
+ break;
+ }
}
- if (!targetAudience.contains(authorizedParty)) {
- throw new OAuthException("ID Token audience, doesn't
contain authorized party");
+ if (!matched) {
+ throw new OAuthException("Invalid JWT audience. Expected
to contain one of: " + expectedAudiences);
}
}
var issuer = jwtOptions.getIssuer();
@@ -257,4 +275,15 @@ public class UserProfile {
}
}
}
+
+ private static List<String> resolveExpectedAudiences(OAuthConfig config,
JWTOptions jwtOptions) {
+ List<String> expectedAudiences = jwtOptions != null ?
jwtOptions.getAudience() : null;
+ if (expectedAudiences == null || expectedAudiences.isEmpty()) {
+ String clientId = config.getClientId();
+ if (clientId != null && !clientId.isBlank()) {
+ expectedAudiences = List.of(clientId);
+ }
+ }
+ return expectedAudiences != null ? expectedAudiences : List.of();
+ }
}
diff --git
a/components/camel-oauth/src/test/java/org/apache/camel/oauth/UserProfileTest.java
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/UserProfileTest.java
index ebb5a2d688c8..6a1631c11bd9 100644
---
a/components/camel-oauth/src/test/java/org/apache/camel/oauth/UserProfileTest.java
+++
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/UserProfileTest.java
@@ -17,6 +17,7 @@
package org.apache.camel.oauth;
import java.util.Date;
+import java.util.List;
import com.google.gson.JsonObject;
import com.nimbusds.jose.JWSAlgorithm;
@@ -112,6 +113,106 @@ class UserProfileTest {
assertThrows(OAuthException.class, () -> UserProfile.fromJson(config,
beyondLeeway));
}
+ @Test
+ void accessTokenRejectedWhenAudienceMismatched() throws Exception {
+ JsonObject json = new JsonObject();
+ json.addProperty("access_token",
signedTokenWithAudience("other-client"));
+
+ OAuthException ex = assertThrows(OAuthException.class, () ->
UserProfile.fromJson(verifyingConfig(), json));
+ assertEquals("Invalid JWT audience. Expected to contain one of:
[my-client]", ex.getMessage());
+ }
+
+ @Test
+ void accessTokenRejectedWhenAudienceMissing() throws Exception {
+ JsonObject json = new JsonObject();
+ json.addProperty("access_token", signedTokenWithAudience((String)
null));
+
+ OAuthException ex = assertThrows(OAuthException.class, () ->
UserProfile.fromJson(verifyingConfig(), json));
+ assertEquals("Invalid JWT audience. Expected to contain one of:
[my-client]", ex.getMessage());
+ }
+
+ @Test
+ void accessTokenAcceptedWhenJwtOptionsAudienceMatches() throws Exception {
+ OAuthConfig config = verifyingConfig();
+ config.getJWTOptions().addAudience("resource-server");
+
+ JsonObject json = new JsonObject();
+ json.addProperty("access_token",
signedTokenWithAudience("resource-server"));
+
+ assertNotNull(UserProfile.fromJson(config, json));
+ }
+
+ @Test
+ void accessTokenRejectedWhenJwtOptionsAudienceMismatched() throws
Exception {
+ OAuthConfig config = verifyingConfig();
+ config.getJWTOptions().addAudience("resource-server");
+
+ JsonObject json = new JsonObject();
+ json.addProperty("access_token", signedTokenWithAudience("my-client"));
+
+ OAuthException ex = assertThrows(OAuthException.class, () ->
UserProfile.fromJson(config, json));
+ assertEquals("Invalid JWT audience. Expected to contain one of:
[resource-server]", ex.getMessage());
+ }
+
+ @Test
+ void accessTokenRejectedWhenNoAudienceConfigured() throws Exception {
+ OAuthConfig config = new OAuthConfig();
+ config.setJWKSet(new JWKSet(rsaKey.toPublicJWK()));
+
+ JsonObject json = new JsonObject();
+ json.addProperty("access_token", signedTokenWithAudience("my-client"));
+
+ OAuthException ex = assertThrows(OAuthException.class, () ->
UserProfile.fromJson(config, json));
+ assertEquals("Token validation requires expected audience; none
configured", ex.getMessage());
+ }
+
+ @Test
+ void accessTokenAcceptedWithMultiValuedAudienceArray() throws Exception {
+ JsonObject json = new JsonObject();
+ json.addProperty("access_token",
signedTokenWithAudiences(List.of("my-client", "other-audience")));
+
+ UserProfile profile = UserProfile.fromJson(verifyingConfig(), json);
+ assertNotNull(profile);
+ }
+
+ @Test
+ void codeExchangeLoginFlowAcceptedWhenAudienceMatchesClientId() throws
Exception {
+ JsonObject tokenResponse = new JsonObject();
+ tokenResponse.addProperty("access_token",
signedTokenWithAudience("my-client"));
+ tokenResponse.addProperty("id_token", signedIdToken("my-client"));
+ tokenResponse.addProperty("expires_in", 3600);
+
+ UserProfile profile = UserProfile.fromJson(verifyingConfig(),
tokenResponse);
+ assertNotNull(profile);
+ assertEquals("alice", profile.subject());
+ }
+
+ @Test
+ void codeExchangeLoginFlowAcceptedWithCustomJwtOptionsAudienceForIdp()
throws Exception {
+ OAuthConfig config = verifyingConfig();
+ config.getJWTOptions().addAudience("account");
+
+ JsonObject tokenResponse = new JsonObject();
+ tokenResponse.addProperty("access_token",
signedTokenWithAudience("account"));
+ tokenResponse.addProperty("id_token", signedIdToken("my-client"));
+ tokenResponse.addProperty("expires_in", 3600);
+
+ UserProfile profile = UserProfile.fromJson(config, tokenResponse);
+ assertNotNull(profile);
+ assertEquals("alice", profile.subject());
+ }
+
+ @Test
+ void codeExchangeLoginFlowRejectedWhenAccessTokenAudienceMismatched()
throws Exception {
+ JsonObject tokenResponse = new JsonObject();
+ tokenResponse.addProperty("access_token",
signedTokenWithAudience("account"));
+ tokenResponse.addProperty("id_token", signedIdToken("my-client"));
+ tokenResponse.addProperty("expires_in", 3600);
+
+ OAuthException ex = assertThrows(OAuthException.class, () ->
UserProfile.fromJson(verifyingConfig(), tokenResponse));
+ assertEquals("Invalid JWT audience. Expected to contain one of:
[my-client]", ex.getMessage());
+ }
+
private OAuthConfig verifyingConfig() {
OAuthConfig config = new OAuthConfig().setClientId("my-client");
config.setJWKSet(new JWKSet(rsaKey.toPublicJWK()));
@@ -123,12 +224,30 @@ class UserProfileTest {
}
private String signedToken(Date notBefore) throws Exception {
+ return signedTokenWithAudience("my-client", notBefore);
+ }
+
+ private String signedTokenWithAudience(String audience) throws Exception {
+ return signedTokenWithAudience(audience, null);
+ }
+
+ private String signedTokenWithAudience(String audience, Date notBefore)
throws Exception {
+ return signedTokenWithAudiences(audience != null ? List.of(audience) :
null, notBefore);
+ }
+
+ private String signedTokenWithAudiences(List<String> audiences) throws
Exception {
+ return signedTokenWithAudiences(audiences, null);
+ }
+
+ private String signedTokenWithAudiences(List<String> audiences, Date
notBefore) throws Exception {
JWTClaimsSet.Builder claims = new JWTClaimsSet.Builder()
.subject("user1")
.issuer("https://idp.example.com")
- .audience("my-client")
.expirationTime(new Date(System.currentTimeMillis() +
300_000L))
.issueTime(new Date());
+ if (audiences != null && !audiences.isEmpty()) {
+ claims.audience(audiences);
+ }
if (notBefore != null) {
claims.notBeforeTime(notBefore);
}
@@ -136,4 +255,19 @@ class UserProfileTest {
jwt.sign(new RSASSASigner(rsaKey));
return jwt.serialize();
}
+
+ private String signedIdToken(String clientId) throws Exception {
+ JWTClaimsSet.Builder claims = new JWTClaimsSet.Builder()
+ .subject("alice")
+ .issuer("https://idp.example.com")
+ .claim("azp", clientId)
+ .claim("name", "Alice")
+ .claim("email", "[email protected]")
+ .expirationTime(new Date(System.currentTimeMillis() +
300_000L))
+ .issueTime(new Date());
+ claims.audience(clientId);
+ SignedJWT jwt = new SignedJWT(new
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(KID).build(), claims.build());
+ jwt.sign(new RSASSASigner(rsaKey));
+ return jwt.serialize();
+ }
}
diff --git
a/components/camel-oauth/src/test/java/org/apache/camel/oauth/jakarta/ServletOAuthTokenCredentialsTest.java
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/jakarta/ServletOAuthTokenCredentialsTest.java
index e88017e1f53b..09cfa0126891 100644
---
a/components/camel-oauth/src/test/java/org/apache/camel/oauth/jakarta/ServletOAuthTokenCredentialsTest.java
+++
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/jakarta/ServletOAuthTokenCredentialsTest.java
@@ -17,6 +17,7 @@
package org.apache.camel.oauth.jakarta;
import java.util.Date;
+import java.util.List;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
@@ -77,15 +78,85 @@ class ServletOAuthTokenCredentialsTest {
assertEquals("user1", profile.subject());
}
+ @Test
+ void bearerTokenRejectedWithMismatchedAudience() throws Exception {
+ String token = signedTokenWithAudience("other-app", null);
+
+ OAuthException ex = assertThrows(OAuthException.class, () ->
oauth.authenticate(new TokenCredentials(token)));
+ assertEquals("Invalid JWT audience. Expected to contain one of:
[my-client]", ex.getCause().getMessage());
+ }
+
+ @Test
+ void bearerTokenRejectedWhenAudienceMissing() throws Exception {
+ String token = signedTokenWithAudience(null, null);
+
+ OAuthException ex = assertThrows(OAuthException.class, () ->
oauth.authenticate(new TokenCredentials(token)));
+ assertEquals("Invalid JWT audience. Expected to contain one of:
[my-client]", ex.getCause().getMessage());
+ }
+
+ @Test
+ void bearerTokenAcceptedWithCustomJwtOptionsAudience() throws Exception {
+ OAuthConfig oauthConfig = new OAuthConfig().setClientId("my-client");
+ oauthConfig.setJWKSet(new JWKSet(rsaKey.toPublicJWK()));
+ oauthConfig.getJWTOptions().setIssuer(ISSUER);
+ oauthConfig.getJWTOptions().addAudience("custom-api");
+ ServletOAuth customOAuth = new ServletOAuth() {
+ {
+ config = oauthConfig;
+ }
+ };
+
+ String token = signedTokenWithAudience("custom-api", null);
+ UserProfile profile = customOAuth.authenticate(new
TokenCredentials(token));
+ assertEquals("user1", profile.subject());
+ }
+
+ @Test
+ void bearerTokenRejectedWhenJwtOptionsAudienceMismatched() throws
Exception {
+ OAuthConfig oauthConfig = new OAuthConfig().setClientId("my-client");
+ oauthConfig.setJWKSet(new JWKSet(rsaKey.toPublicJWK()));
+ oauthConfig.getJWTOptions().setIssuer(ISSUER);
+ oauthConfig.getJWTOptions().addAudience("custom-api");
+ ServletOAuth customOAuth = new ServletOAuth() {
+ {
+ config = oauthConfig;
+ }
+ };
+
+ String token = signedTokenWithAudience("my-client", null);
+ OAuthException ex = assertThrows(OAuthException.class, () ->
customOAuth.authenticate(new TokenCredentials(token)));
+ assertEquals("Invalid JWT audience. Expected to contain one of:
[custom-api]", ex.getCause().getMessage());
+ }
+
+ @Test
+ void bearerTokenAcceptedWithMultiValuedAudienceArray() throws Exception {
+ String token = signedTokenWithAudiences(List.of("my-client",
"other-api"), null);
+
+ UserProfile profile = oauth.authenticate(new TokenCredentials(token));
+ assertEquals("user1", profile.subject());
+ }
+
private String signedToken(Date notBefore) throws Exception {
- JWTClaimsSet claims = new JWTClaimsSet.Builder()
+ return signedTokenWithAudience("my-client", notBefore);
+ }
+
+ private String signedTokenWithAudience(String audience, Date notBefore)
throws Exception {
+ return signedTokenWithAudiences(audience != null ? List.of(audience) :
null, notBefore);
+ }
+
+ private String signedTokenWithAudiences(List<String> audiences, Date
notBefore) throws Exception {
+ JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
.subject("user1")
.issuer(ISSUER)
.expirationTime(new Date(System.currentTimeMillis() +
7_200_000L))
- .issueTime(new Date())
- .notBeforeTime(notBefore)
- .build();
- SignedJWT jwt = new SignedJWT(new
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(KID).build(), claims);
+ .issueTime(new Date());
+ if (audiences != null && !audiences.isEmpty()) {
+ builder.audience(audiences);
+ }
+ if (notBefore != null) {
+ builder.notBeforeTime(notBefore);
+ }
+ SignedJWT jwt = new SignedJWT(new
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(KID).build(), builder.build());
jwt.sign(new RSASSASigner(rsaKey));
return jwt.serialize();
}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index b257ec4123cf..0305278d5169 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -1776,6 +1776,17 @@ identifier upon successful authentication, replacing the
pre-login session ident
and emitting an updated session cookie. The default `rotateSession` method of
`OAuthSessionStore` keeps the session
unchanged, so a custom `OAuthSessionStore` must override it to rotate the
session identifier on login.
+The Jakarta servlet backend (`ServletOAuth`) now validates the `aud`
(audience) claim of access tokens evaluated by
+`UserProfile` and `OAuthBearerTokenProcessor`. Tokens are validated against
configured expected audiences on
+`JWTOptions` (via `config.getJWTOptions().addAudience(String)` or
`config.getJWTOptions().setAudience(List<String>)`),
+falling back to the configured `camel.oauth.client-id` when no custom audience
is configured. Tokens whose audience does
+not match or tokens without an audience claim are rejected with
`OAuthException`.
+
+For OpenID Connect / Keycloak login flows, Keycloak access tokens commonly
carry `aud=account` by default unless an
+audience protocol mapper is configured on the client. Applications upgrading
that use Keycloak for authorization code
+login flows should configure an audience mapper on the client in Keycloak or
configure the expected audience on `JWTOptions`
+(for example, `config.getJWTOptions().addAudience("account")`).
+
=== camel-platform-http-vertx - the charset parameter of the request
Content-Type in any case
The consumer now reads the charset parameter of the request `Content-Type`
whatever the case of its name, as