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 <[email protected]> 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));
