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 7fcd9396e1cd [backport camel-4.22.x] CAMEL-26404: camel-oauth: 
validate audience for access tokens on ServletOAuth backend (#28700)
7fcd9396e1cd is described below

commit 7fcd9396e1cde785f77dc08fd5ed131d909570f5
Author: Andrea Cosentino <[email protected]>
AuthorDate: Thu Oct 29 21:10:54 2026 +0100

    [backport camel-4.22.x] CAMEL-26404: camel-oauth: validate audience for 
access tokens on ServletOAuth backend (#28700)
    
    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.
    
    
    (cherry picked from commit d1a4478a70c18ab5feaed0692bb3c07fa70e3323)
    
    Co-authored-by: Claude Opus 4.6 <[email protected]>
---
 .../java/org/apache/camel/oauth/UserProfile.java   |  47 +++++--
 .../org/apache/camel/oauth/UserProfileTest.java    | 142 ++++++++++++++++++++-
 .../jakarta/ServletOAuthTokenCredentialsTest.java  | 140 ++++++++++++++++++++
 3 files changed, 315 insertions(+), 14 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 71394c10ef47..58477294bf1d 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();
@@ -250,4 +268,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 a246c259880b..96282931ed42 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;
@@ -30,6 +31,7 @@ import com.nimbusds.jwt.SignedJWT;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
@@ -79,15 +81,145 @@ class UserProfileTest {
         assertNotNull(profile);
     }
 
+    @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()));
+        return config;
+    }
+
     private String signedToken() throws Exception {
-        JWTClaimsSet claims = new JWTClaimsSet.Builder()
+        return signedTokenWithAudience("my-client");
+    }
+
+    private String signedTokenWithAudience(String audience) throws Exception {
+        return signedTokenWithAudiences(audience != null ? List.of(audience) : 
null);
+    }
+
+    private String signedTokenWithAudiences(List<String> audiences) 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())
-                .build();
-        SignedJWT jwt = new SignedJWT(new 
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(KID).build(), claims);
+                .issueTime(new Date());
+        if (audiences != null && !audiences.isEmpty()) {
+            claims.audience(audiences);
+        }
+        SignedJWT jwt = new SignedJWT(new 
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(KID).build(), claims.build());
+        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
new file mode 100644
index 000000000000..96ef9beb99b2
--- /dev/null
+++ 
b/components/camel-oauth/src/test/java/org/apache/camel/oauth/jakarta/ServletOAuthTokenCredentialsTest.java
@@ -0,0 +1,140 @@
+/*
+ * 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.jakarta;
+
+import java.util.Date;
+import java.util.List;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import org.apache.camel.oauth.OAuthConfig;
+import org.apache.camel.oauth.OAuthException;
+import org.apache.camel.oauth.TokenCredentials;
+import org.apache.camel.oauth.UserProfile;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Authenticates bearer tokens the way {@code OAuthBearerTokenProcessor} does 
on the servlet backend. The tokens verify
+ * against a local JWK set and are not expired, so no identity provider is 
contacted.
+ */
+class ServletOAuthTokenCredentialsTest {
+
+    private static final String KID = "test-key-1";
+    private static final String ISSUER = "https://idp.example.com";;
+
+    private RSAKey rsaKey;
+    private ServletOAuth oauth;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        rsaKey = new RSAKeyGenerator(2048).keyID(KID).generate();
+        OAuthConfig oauthConfig = new OAuthConfig().setClientId("my-client");
+        oauthConfig.setJWKSet(new JWKSet(rsaKey.toPublicJWK()));
+        oauthConfig.getJWTOptions().setIssuer(ISSUER);
+        oauth = new ServletOAuth() {
+            {
+                config = oauthConfig;
+            }
+        };
+    }
+
+    @Test
+    void bearerTokenRejectedWithMismatchedAudience() throws Exception {
+        String token = signedTokenWithAudience("other-app");
+
+        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);
+
+        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");
+        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");
+        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"));
+
+        UserProfile profile = oauth.authenticate(new TokenCredentials(token));
+        assertEquals("user1", profile.subject());
+    }
+
+    private String signedTokenWithAudience(String audience) throws Exception {
+        return signedTokenWithAudiences(audience != null ? List.of(audience) : 
null);
+    }
+
+    private String signedTokenWithAudiences(List<String> audiences) throws 
Exception {
+        JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
+                .subject("user1")
+                .issuer(ISSUER)
+                .expirationTime(new Date(System.currentTimeMillis() + 
7_200_000L))
+                .issueTime(new Date());
+        if (audiences != null && !audiences.isEmpty()) {
+            builder.audience(audiences);
+        }
+        SignedJWT jwt = new SignedJWT(new 
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(KID).build(), builder.build());
+        jwt.sign(new RSASSASigner(rsaKey));
+        return jwt.serialize();
+    }
+}

Reply via email to