You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cxf.apache.org by co...@apache.org on 2026/09/29 10:26:11 UTC
(cxf) 01/01: Harden JWT issuer and audience validation
This is an automated email from the ASF dual-hosted git repository.
coheigea pushed a commit to branch coheigea/jose
in repository https://gitbox.apache.org/repos/asf/cxf.git
commit d7df7294f86d898550ed5e68be61ad142650ff1f
Author: Colm O hEigeartaigh <co...@apache.org>
AuthorDate: Tue Sep 29 11:25:50 2026 +0100
Harden JWT issuer and audience validation
---
.../jaxrs/AbstractJwtAuthenticationFilter.java | 41 ++++++++
.../jose/jaxrs/JwtAuthenticationFilterTest.java | 116 +++++++++++++++++++++
.../oauth2/grants/jwt/AbstractJwtHandler.java | 5 +
.../oauth2/grants/jwt/JwtBearerAuthHandler.java | 5 +
.../grants/jwt/JwtBearerGrantHandlerTest.java | 32 +++++-
5 files changed, 196 insertions(+), 3 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 7c99b7fd530..ddc2778deb4 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,8 @@
package org.apache.cxf.rs.security.jose.jaxrs;
import java.io.IOException;
+import java.util.List;
+import java.util.Set;
import java.util.logging.Logger;
import jakarta.annotation.Priority;
@@ -33,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;
@@ -45,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,6 +96,22 @@ public abstract class AbstractJwtAuthenticationFilter extends JoseJwtConsumer im
@Override
protected void validateToken(JwtToken jwt) {
JwtUtils.validateTokenClaims(jwt.getClaims(), getTtl(), getClockOffset(), isValidateAudience());
+ validateAudiencePresent(jwt.getClaims().getAudiences());
+ validateIssuer(jwt.getClaims().getIssuer());
+ }
+
+ protected void validateAudiencePresent(List<String> audiences) {
+ // If the audience is required, an "aud" claim must be present
+ if (requireAudience && audiences.isEmpty()) {
+ throw new JwtException("Invalid audience restriction");
+ }
+ }
+
+ 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() {
@@ -109,4 +130,24 @@ 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.
+ */
+ 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..24695e9fc04
--- /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,116 @@
+/**
+ * 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.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.util.Collections;
+
+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.Test;
+
+import static org.junit.Assert.fail;
+
+public class JwtAuthenticationFilterTest {
+
+ @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 = createFilter();
+ filter.validateToken(new JwtToken(createClaims(null)));
+ }
+
+ @Test
+ public void testAudienceAcceptedWithRequireAudience() {
+ JwtAuthenticationFilter filter = createFilter();
+ filter.setRequireAudience(true);
+ JwtClaims claims = createClaims(null);
+ claims.setAudience("https://service.example.com");
+ filter.validateToken(new JwtToken(claims));
+ }
+
+ @Test
+ public void testNoAudienceRejectedWithRequireAudience() {
+ JwtAuthenticationFilter filter = createFilter();
+ 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;
+ }
+}
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..a18bebe5844 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
@@ -69,6 +69,11 @@ public abstract class AbstractJwtHandler extends AbstractGrantHandler {
if (claims.getClaim(JwtConstants.CLAIM_EXPIRY) == null) {
throw new OAuthServiceException(OAuthConstants.INVALID_GRANT);
}
+
+ // We must have an Audience (RFC 7523, section 3)
+ if (claims.getAudiences().isEmpty()) {
+ throw new OAuthServiceException(OAuthConstants.INVALID_GRANT);
+ }
}
protected void validateIssuer(String issuer) {
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 8519a094e9d..aaeb3a80a8d 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
@@ -130,6 +130,11 @@ public class JwtBearerAuthHandler extends OAuthServerJoseJwtConsumer implements
throw new OAuthServiceException(OAuthConstants.INVALID_GRANT);
}
+ // We must have an Audience (RFC 7523, section 3)
+ if (isValidateAudience() && jwt.getClaims().getAudiences().isEmpty()) {
+ throw new OAuthServiceException(OAuthConstants.INVALID_GRANT);
+ }
+
JwtUtils.validateTokenClaims(jwt.getClaims(), getTtl(), getClockOffset(), isValidateAudience());
}
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 2e0b41c43c5..243f39037a1 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
@@ -48,6 +48,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 {
@@ -64,9 +65,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);
@@ -84,9 +86,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);
@@ -98,13 +101,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));