This is an automated email from the ASF dual-hosted git repository.

coheigea pushed a commit to branch 3.6.x-fixes
in repository https://gitbox.apache.org/repos/asf/cxf.git


The following commit(s) were added to refs/heads/3.6.x-fixes by this push:
     new 7a56884791a Harden JWT issuer and audience validation (#3518)
7a56884791a is described below

commit 7a56884791ac74d0b25f21e99b9ea7c28118e4ab
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Wed Sep 30 08:21:11 2026 +0100

    Harden JWT issuer and audience validation (#3518)
    
    * Harden JWT issuer and audience validation
    
    * Consolidating work
    
    (cherry picked from commit 8b616ab64e38f13b8ab6fb8573fd19e0ea6503f7)
---
 .../jaxrs/AbstractJwtAuthenticationFilter.java     |  36 ++++-
 .../jose/jaxrs/JwtAuthenticationFilterTest.java    | 148 +++++++++++++++++++++
 .../apache/cxf/rs/security/jose/jwt/JwtUtils.java  |  18 ++-
 .../oauth2/grants/jwt/AbstractJwtHandler.java      |   3 +-
 .../oauth2/grants/jwt/JwtBearerAuthHandler.java    |   3 +-
 .../grants/jwt/JwtBearerGrantHandlerTest.java      |  32 ++++-
 .../security/oauth2/grants/JAXRSOAuth2Test.java    |  21 +++
 7 files changed, 253 insertions(+), 8 deletions(-)

diff --git 
a/rt/rs/security/jose-parent/jose-jaxrs/src/main/java/org/apache/cxf/rs/security/jose/jaxrs/AbstractJwtAuthenticationFilter.java
 
b/rt/rs/security/jose-parent/jose-jaxrs/src/main/java/org/apache/cxf/rs/security/jose/jaxrs/AbstractJwtAuthenticationFilter.java
index b7e88f53734..95215991b1b 100644
--- 
a/rt/rs/security/jose-parent/jose-jaxrs/src/main/java/org/apache/cxf/rs/security/jose/jaxrs/AbstractJwtAuthenticationFilter.java
+++ 
b/rt/rs/security/jose-parent/jose-jaxrs/src/main/java/org/apache/cxf/rs/security/jose/jaxrs/AbstractJwtAuthenticationFilter.java
@@ -19,6 +19,7 @@
 package org.apache.cxf.rs.security.jose.jaxrs;
 
 import java.io.IOException;
+import java.util.Set;
 import java.util.logging.Logger;
 
 import javax.annotation.Priority;
@@ -34,6 +35,7 @@ import org.apache.cxf.message.MessageUtils;
 import org.apache.cxf.rs.security.jose.common.JoseConstants;
 import org.apache.cxf.rs.security.jose.jwa.SignatureAlgorithm;
 import org.apache.cxf.rs.security.jose.jwt.JoseJwtConsumer;
+import org.apache.cxf.rs.security.jose.jwt.JwtException;
 import org.apache.cxf.rs.security.jose.jwt.JwtToken;
 import org.apache.cxf.rs.security.jose.jwt.JwtUtils;
 import org.apache.cxf.security.SecurityContext;
@@ -46,6 +48,8 @@ public abstract class AbstractJwtAuthenticationFilter extends 
JoseJwtConsumer im
 
     private String roleClaim;
     private boolean validateAudience = true;
+    private boolean requireAudience;
+    private Set<String> supportedIssuers;
 
     protected AbstractJwtAuthenticationFilter() {
         setTtl(DEFAULT_TTL_SECS);
@@ -91,7 +95,16 @@ public abstract class AbstractJwtAuthenticationFilter 
extends JoseJwtConsumer im
 
     @Override
     protected void validateToken(JwtToken jwt) {
-        JwtUtils.validateTokenClaims(jwt.getClaims(), getTtl(), 
getClockOffset(), isValidateAudience());
+        JwtUtils.validateTokenClaims(jwt.getClaims(), getTtl(), 
getClockOffset(), isValidateAudience(),
+                                     isRequireAudience());
+        validateIssuer(jwt.getClaims().getIssuer());
+    }
+
+    protected void validateIssuer(String issuer) {
+        // If supported issuers are configured, a matching "iss" must be 
present
+        if (supportedIssuers != null && (issuer == null || 
!supportedIssuers.contains(issuer))) {
+            throw new JwtException("Invalid issuer");
+        }
     }
 
     public String getRoleClaim() {
@@ -110,4 +123,25 @@ public abstract class AbstractJwtAuthenticationFilter 
extends JoseJwtConsumer im
         this.validateAudience = validateAudience;
     }
 
+    public boolean isRequireAudience() {
+        return requireAudience;
+    }
+
+    /**
+     * Reject tokens which do not contain an "aud" claim. By default, a token 
without an "aud" claim
+     * passes the audience restriction check unless 
JwtConstants.EXPECTED_CLAIM_AUDIENCE is configured.
+     * This only applies if "validateAudience" is enabled.
+     */
+    public void setRequireAudience(boolean requireAudience) {
+        this.requireAudience = requireAudience;
+    }
+
+    public Set<String> getSupportedIssuers() {
+        return supportedIssuers;
+    }
+
+    public void setSupportedIssuers(Set<String> supportedIssuers) {
+        this.supportedIssuers = supportedIssuers;
+    }
+
 }
diff --git 
a/rt/rs/security/jose-parent/jose-jaxrs/src/test/java/org/apache/cxf/rs/security/jose/jaxrs/JwtAuthenticationFilterTest.java
 
b/rt/rs/security/jose-parent/jose-jaxrs/src/test/java/org/apache/cxf/rs/security/jose/jaxrs/JwtAuthenticationFilterTest.java
new file mode 100644
index 00000000000..a54002997ab
--- /dev/null
+++ 
b/rt/rs/security/jose-parent/jose-jaxrs/src/test/java/org/apache/cxf/rs/security/jose/jaxrs/JwtAuthenticationFilterTest.java
@@ -0,0 +1,148 @@
+/**
+ * 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.cxf.rs.security.jose.jaxrs;
+
+import java.lang.reflect.Field;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.Collections;
+
+import org.apache.cxf.message.Message;
+import org.apache.cxf.message.MessageImpl;
+import org.apache.cxf.phase.PhaseInterceptorChain;
+import org.apache.cxf.rs.security.jose.jwt.JwtClaims;
+import org.apache.cxf.rs.security.jose.jwt.JwtException;
+import org.apache.cxf.rs.security.jose.jwt.JwtToken;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.junit.Assert.fail;
+
+public class JwtAuthenticationFilterTest {
+
+    private static final String AUDIENCE = "https://service.example.com";;
+
+    @Before
+    public void setUp() throws Exception {
+        Message message = new MessageImpl();
+        message.put(Message.REQUEST_URL, AUDIENCE);
+        setThreadLocalMessage(message);
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        setThreadLocalMessage(null);
+    }
+
+    @Test
+    public void testNoIssuerAcceptedWithoutSupportedIssuers() {
+        JwtAuthenticationFilter filter = createFilter();
+        filter.validateToken(new JwtToken(createClaims(null)));
+    }
+
+    @Test
+    public void testSupportedIssuerAccepted() {
+        JwtAuthenticationFilter filter = createFilter();
+        
filter.setSupportedIssuers(Collections.singleton("https://issuer.example.com";));
+        filter.validateToken(new 
JwtToken(createClaims("https://issuer.example.com";)));
+    }
+
+    @Test
+    public void testUnsupportedIssuerRejected() {
+        JwtAuthenticationFilter filter = createFilter();
+        
filter.setSupportedIssuers(Collections.singleton("https://issuer.example.com";));
+        try {
+            filter.validateToken(new 
JwtToken(createClaims("https://other.example.com";)));
+            fail("Failure expected on an unsupported issuer");
+        } catch (JwtException ex) {
+            // expected
+        }
+    }
+
+    @Test
+    public void testMissingIssuerRejectedWithSupportedIssuers() {
+        JwtAuthenticationFilter filter = createFilter();
+        
filter.setSupportedIssuers(Collections.singleton("https://issuer.example.com";));
+        try {
+            filter.validateToken(new JwtToken(createClaims(null)));
+            fail("Failure expected on a missing issuer");
+        } catch (JwtException ex) {
+            // expected
+        }
+    }
+
+    @Test
+    public void testNoAudienceAcceptedByDefault() {
+        JwtAuthenticationFilter filter = new JwtAuthenticationFilter();
+        filter.validateToken(new JwtToken(createClaims(null)));
+    }
+
+    @Test
+    public void testAudienceAcceptedWithRequireAudience() {
+        JwtAuthenticationFilter filter = new JwtAuthenticationFilter();
+        filter.setRequireAudience(true);
+        JwtClaims claims = createClaims(null);
+        claims.setAudience(AUDIENCE);
+        filter.validateToken(new JwtToken(claims));
+    }
+
+    @Test
+    public void testNoAudienceRejectedWithRequireAudience() {
+        JwtAuthenticationFilter filter = new JwtAuthenticationFilter();
+        filter.setRequireAudience(true);
+        try {
+            filter.validateToken(new JwtToken(createClaims(null)));
+            fail("Failure expected on a missing audience");
+        } catch (JwtException ex) {
+            // expected
+        }
+    }
+
+    private static JwtAuthenticationFilter createFilter() {
+        JwtAuthenticationFilter filter = new JwtAuthenticationFilter();
+        filter.setValidateAudience(false);
+        return filter;
+    }
+
+    private static JwtClaims createClaims(String issuer) {
+        JwtClaims claims = new JwtClaims();
+        claims.setSubject("alice");
+        if (issuer != null) {
+            claims.setIssuer(issuer);
+        }
+        ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
+        claims.setIssuedAt(now.toEpochSecond());
+        claims.setExpiryTime(now.plusMinutes(5L).toEpochSecond());
+        return claims;
+    }
+
+    private static void setThreadLocalMessage(Message message) throws 
Exception {
+        Field f = 
PhaseInterceptorChain.class.getDeclaredField("CURRENT_MESSAGE");
+        f.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        ThreadLocal<Message> tl = (ThreadLocal<Message>) f.get(null);
+        if (message == null) {
+            tl.remove();
+        } else {
+            tl.set(message);
+        }
+    }
+}
diff --git 
a/rt/rs/security/jose-parent/jose/src/main/java/org/apache/cxf/rs/security/jose/jwt/JwtUtils.java
 
b/rt/rs/security/jose-parent/jose/src/main/java/org/apache/cxf/rs/security/jose/jwt/JwtUtils.java
index 6c47611e8ab..28ff08a402c 100644
--- 
a/rt/rs/security/jose-parent/jose/src/main/java/org/apache/cxf/rs/security/jose/jwt/JwtUtils.java
+++ 
b/rt/rs/security/jose-parent/jose/src/main/java/org/apache/cxf/rs/security/jose/jwt/JwtUtils.java
@@ -132,6 +132,11 @@ public final class JwtUtils {
     }
 
     public static void validateJwtAudienceRestriction(JwtClaims claims, 
Message message) {
+        validateJwtAudienceRestriction(claims, message, false);
+    }
+
+    public static void validateJwtAudienceRestriction(JwtClaims claims, 
Message message,
+                                                      boolean 
audienceRequired) {
         // If the expected audience is configured, a matching "aud" must be 
present
         String expectedAudience = 
(String)message.getContextualProperty(JwtConstants.EXPECTED_CLAIM_AUDIENCE);
         if (expectedAudience != null) {
@@ -141,8 +146,11 @@ public final class JwtUtils {
             throw new JwtException("Invalid audience restriction");
         }
 
-        // Otherwise if we have no aud claims then the token is valid
+        // Otherwise if we have no aud claims then the token is valid, unless 
an audience is required
         if (claims.getAudiences().isEmpty()) {
+            if (audienceRequired) {
+                throw new JwtException("Invalid audience restriction");
+            }
             return;
         }
 
@@ -157,6 +165,11 @@ public final class JwtUtils {
 
     public static void validateTokenClaims(JwtClaims claims, int timeToLive, 
int clockOffset,
                                            boolean 
validateAudienceRestriction) {
+        validateTokenClaims(claims, timeToLive, clockOffset, 
validateAudienceRestriction, false);
+    }
+
+    public static void validateTokenClaims(JwtClaims claims, int timeToLive, 
int clockOffset,
+                                           boolean 
validateAudienceRestriction, boolean audienceRequired) {
         // A positive TTL bounds the token age from its issued time.
         boolean expiredRequired = claims.getIssuedAt() == null || timeToLive 
<= 0;
         validateJwtExpiry(claims, clockOffset, expiredRequired);
@@ -168,7 +181,8 @@ public final class JwtUtils {
         validateJwtIssuedAt(claims, timeToLive, clockOffset, issuedAtRequired);
 
         if (validateAudienceRestriction) {
-            validateJwtAudienceRestriction(claims, 
PhaseInterceptorChain.getCurrentMessage());
+            validateJwtAudienceRestriction(claims, 
PhaseInterceptorChain.getCurrentMessage(),
+                                           audienceRequired);
         }
     }
 
diff --git 
a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/jwt/AbstractJwtHandler.java
 
b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/jwt/AbstractJwtHandler.java
index 8f962334223..d4377444fac 100644
--- 
a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/jwt/AbstractJwtHandler.java
+++ 
b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/jwt/AbstractJwtHandler.java
@@ -60,7 +60,8 @@ public abstract class AbstractJwtHandler extends 
AbstractGrantHandler {
         if (getAudience() != null) {
             
JAXRSUtils.getCurrentMessage().put(JwtConstants.EXPECTED_CLAIM_AUDIENCE, 
getAudience());
         }
-        JwtUtils.validateTokenClaims(claims, ttl, clockOffset, true);
+        // We must have an Audience (RFC 7523, section 3)
+        JwtUtils.validateTokenClaims(claims, ttl, clockOffset, true, true);
 
         validateIssuer(claims.getIssuer());
         validateSubject(client, claims.getSubject());
diff --git 
a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/jwt/JwtBearerAuthHandler.java
 
b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/jwt/JwtBearerAuthHandler.java
index 60572d61554..e2f86cc4cd4 100644
--- 
a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/jwt/JwtBearerAuthHandler.java
+++ 
b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/grants/jwt/JwtBearerAuthHandler.java
@@ -131,7 +131,8 @@ public class JwtBearerAuthHandler extends 
OAuthServerJoseJwtConsumer implements
             throw new OAuthServiceException(OAuthConstants.INVALID_GRANT);
         }
 
-        JwtUtils.validateTokenClaims(jwt.getClaims(), getTtl(), 
getClockOffset(), isValidateAudience());
+        // We must have an Audience (RFC 7523, section 3)
+        JwtUtils.validateTokenClaims(jwt.getClaims(), getTtl(), 
getClockOffset(), isValidateAudience(), true);
     }
 
     public void setClientProvider(ClientRegistrationProvider clientProvider) {
diff --git 
a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/grants/jwt/JwtBearerGrantHandlerTest.java
 
b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/grants/jwt/JwtBearerGrantHandlerTest.java
index 2958bc788f7..2a4b545c1ff 100644
--- 
a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/grants/jwt/JwtBearerGrantHandlerTest.java
+++ 
b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/grants/jwt/JwtBearerGrantHandlerTest.java
@@ -49,6 +49,7 @@ import static org.junit.Assert.fail;
 public class JwtBearerGrantHandlerTest {
 
     private static final String SIGNING_KEY = "jwt-grant-test-signing-key";
+    private static final String AUDIENCE = 
"https://localhost:8080/oauth2/token";;
 
     @Before
     public void setUp() throws Exception {
@@ -65,9 +66,10 @@ public class JwtBearerGrantHandlerTest {
         JwtBearerGrantHandler handler = new JwtBearerGrantHandler();
         handler.setDataProvider(new SubjectAwareDataProvider());
         handler.setJwsVerifier(new HmacJwsSignatureVerifier(SIGNING_KEY, 
SignatureAlgorithm.HS256));
+        handler.setAudience(AUDIENCE);
 
         Client client = new Client("fuzz-client", "secret", true);
-        String assertion = createSignedAssertion("trusted-issuer", 
"victim-user");
+        String assertion = createSignedAssertion("trusted-issuer", 
"victim-user", AUDIENCE);
 
         MultivaluedMap<String, String> params = new MetadataMap<>();
         params.putSingle(Constants.CLIENT_GRANT_ASSERTION_PARAM, assertion);
@@ -85,9 +87,10 @@ public class JwtBearerGrantHandlerTest {
         JwtBearerGrantHandler handler = new JwtBearerGrantHandler();
         handler.setDataProvider(new SubjectAwareDataProvider());
         handler.setJwsVerifier(new HmacJwsSignatureVerifier(SIGNING_KEY, 
SignatureAlgorithm.HS256));
+        handler.setAudience(AUDIENCE);
 
         Client client = new Client("fuzz-client", "secret", true);
-        String assertion = createSignedAssertion("trusted-issuer", 
client.getClientId());
+        String assertion = createSignedAssertion("trusted-issuer", 
client.getClientId(), AUDIENCE);
 
         MultivaluedMap<String, String> params = new MetadataMap<>();
         params.putSingle(Constants.CLIENT_GRANT_ASSERTION_PARAM, assertion);
@@ -99,13 +102,36 @@ public class JwtBearerGrantHandlerTest {
         assertEquals(client.getClientId(), token.getSubject().getLogin());
     }
 
-    private static String createSignedAssertion(String issuer, String subject) 
{
+    @Test
+    public void testMissingAudienceRejected() {
+        JwtBearerGrantHandler handler = new JwtBearerGrantHandler();
+        handler.setDataProvider(new SubjectAwareDataProvider());
+        handler.setJwsVerifier(new HmacJwsSignatureVerifier(SIGNING_KEY, 
SignatureAlgorithm.HS256));
+
+        Client client = new Client("fuzz-client", "secret", true);
+        String assertion = createSignedAssertion("trusted-issuer", 
client.getClientId(), null);
+
+        MultivaluedMap<String, String> params = new MetadataMap<>();
+        params.putSingle(Constants.CLIENT_GRANT_ASSERTION_PARAM, assertion);
+
+        try {
+            handler.createAccessToken(client, params);
+            fail("OAuthServiceException expected");
+        } catch (OAuthServiceException expected) {
+            assertEquals("invalid_grant", expected.getMessage());
+        }
+    }
+
+    private static String createSignedAssertion(String issuer, String subject, 
String audience) {
         long now = System.currentTimeMillis() / 1000;
         JwtClaims claims = new JwtClaims();
         claims.setIssuer(issuer);
         claims.setSubject(subject);
         claims.setIssuedAt(now);
         claims.setExpiryTime(now + 300);
+        if (audience != null) {
+            claims.setAudience(audience);
+        }
 
         JwsJwtCompactProducer producer = new JwsJwtCompactProducer(claims);
         return producer.signWith(new HmacJwsSignatureProvider(SIGNING_KEY, 
SignatureAlgorithm.HS256));
diff --git 
a/systests/rs-security/src/test/java/org/apache/cxf/systest/jaxrs/security/oauth2/grants/JAXRSOAuth2Test.java
 
b/systests/rs-security/src/test/java/org/apache/cxf/systest/jaxrs/security/oauth2/grants/JAXRSOAuth2Test.java
index 17a6493c2b3..8db39aac17d 100644
--- 
a/systests/rs-security/src/test/java/org/apache/cxf/systest/jaxrs/security/oauth2/grants/JAXRSOAuth2Test.java
+++ 
b/systests/rs-security/src/test/java/org/apache/cxf/systest/jaxrs/security/oauth2/grants/JAXRSOAuth2Test.java
@@ -552,6 +552,27 @@ public class JAXRSOAuth2Test extends 
AbstractBusClientServerTestBase {
         }
     }
 
+    @Test
+    public void testJWTNoAudience() throws Exception {
+        String address = "https://localhost:"; + port + 
"/oauth2-auth-jwt/token";
+        WebClient wc = createWebClient(address);
+
+        // Create the JWT Token
+        String token = OAuth2TestUtils.createToken("resourceOwner", "alice", 
null, true, true);
+
+        Map<String, String> extraParams = new HashMap<>();
+        extraParams.put(Constants.CLIENT_AUTH_ASSERTION_TYPE,
+                        
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
+        extraParams.put(Constants.CLIENT_AUTH_ASSERTION_PARAM, token);
+
+        try {
+            OAuthClientUtils.getAccessToken(wc, new CustomGrant(), 
extraParams);
+            fail("Failure expected on no audience");
+        } catch (Exception ex) {
+            // expected
+        }
+    }
+
     @Test
     public void testJWTBadAudienceRestriction() throws Exception {
         String address = "https://localhost:"; + port + 
"/oauth2-auth-jwt/token";

Reply via email to