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

Reply via email to