This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/audience in repository https://gitbox.apache.org/repos/asf/cxf.git
commit a1c99bbf067f68074a66ccf387119091a1082ac3 Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Wed Sep 30 14:40:07 2026 +0100 Adding a flag to require an audience in JwtAccessTokenValidator --- .../oauth2/filters/JwtAccessTokenValidator.java | 17 +++- .../oauth2/filters/OAuthRequestFilter.java | 17 +++- .../filters/JwtAccessTokenValidatorTest.java | 33 ++++++++ .../oauth2/filters/OAuthRequestFilterTest.java | 95 ++++++++++++++++++++++ 4 files changed, 160 insertions(+), 2 deletions(-) diff --git a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/JwtAccessTokenValidator.java b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/JwtAccessTokenValidator.java index 01f333fede1..5e8e1197c68 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/JwtAccessTokenValidator.java +++ b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/JwtAccessTokenValidator.java @@ -51,6 +51,7 @@ public class JwtAccessTokenValidator extends JoseJwtConsumer implements AccessTo private Map<String, String> jwtAccessTokenClaimMap; private boolean validateAudience = true; + private boolean requireAudience; public List<String> getSupportedAuthorizationSchemes() { return Collections.singletonList(OAuthConstants.BEARER_AUTHORIZATION_SCHEME); @@ -78,7 +79,8 @@ public class JwtAccessTokenValidator extends JoseJwtConsumer implements AccessTo throw new OAuthServiceException(OAuthConstants.INVALID_GRANT); } - JwtUtils.validateTokenClaims(jwt.getClaims(), getTtl(), getClockOffset(), isValidateAudience()); + JwtUtils.validateTokenClaims(jwt.getClaims(), getTtl(), getClockOffset(), isValidateAudience(), + isRequireAudience()); } private void validateTokenType(JwtToken jwt) { @@ -169,4 +171,17 @@ public class JwtAccessTokenValidator extends JoseJwtConsumer implements AccessTo public void setValidateAudience(boolean validateAudience) { 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; + } } diff --git a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilter.java b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilter.java index f38f3ead660..c13844eeb2e 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilter.java +++ b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilter.java @@ -23,6 +23,7 @@ import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Logger; import jakarta.annotation.Priority; @@ -47,6 +48,7 @@ import org.apache.cxf.jaxrs.utils.JAXRSUtils; import org.apache.cxf.message.Message; import org.apache.cxf.phase.PhaseInterceptorChain; import org.apache.cxf.rs.security.jose.common.JoseConstants; +import org.apache.cxf.rs.security.jose.jwt.JwtConstants; import org.apache.cxf.rs.security.oauth2.common.AccessTokenValidation; import org.apache.cxf.rs.security.oauth2.common.AuthenticationMethod; import org.apache.cxf.rs.security.oauth2.common.OAuthContext; @@ -80,6 +82,7 @@ public class OAuthRequestFilter extends AbstractAccessTokenValidator private boolean allPermissionsMatch; private boolean blockPublicClients; private AuthenticationMethod am; + private final AtomicBoolean audienceWarningLogged = new AtomicBoolean(); @Override public void filter(ContainerRequestContext context) { @@ -101,6 +104,12 @@ public class OAuthRequestFilter extends AbstractAccessTokenValidator String authScheme = authParts[0]; String authSchemeData = authParts[1]; + // Make the configured audience available to JWT access token validators, unless an + // expected audience has already been configured + if (audience != null && m.getContextualProperty(JwtConstants.EXPECTED_CLAIM_AUDIENCE) == null) { + m.put(JwtConstants.EXPECTED_CLAIM_AUDIENCE, audience); + } + // Get the access token AccessTokenValidation accessTokenV = getAccessTokenValidation(authScheme, authSchemeData, null); if (!accessTokenV.isInitialValidationSuccessful()) { @@ -272,11 +281,17 @@ public class OAuthRequestFilter extends AbstractAccessTokenValidator } protected String validateAudiences(List<String> audiences) { + if (audience == null && !audienceIsEndpointAddress + && audienceWarningLogged.compareAndSet(false, true)) { + LOG.warning("No audience is configured and audienceIsEndpointAddress is disabled, so access " + + "token audiences will not be validated. Configure the \"audience\" property to restrict " + + "this resource server to tokens issued for it."); + } if (StringUtils.isEmpty(audiences) && audience == null) { return null; } if (audience != null) { - if (audiences.contains(audience)) { + if (audiences != null && audiences.contains(audience)) { return audience; } AuthorizationUtils.throwAuthorizationFailure(supportedSchemes, realm); diff --git a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/filters/JwtAccessTokenValidatorTest.java b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/filters/JwtAccessTokenValidatorTest.java index 327390d3739..38ecb6ac8be 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/filters/JwtAccessTokenValidatorTest.java +++ b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/filters/JwtAccessTokenValidatorTest.java @@ -179,6 +179,39 @@ public class JwtAccessTokenValidatorTest { assertTrue(ex.getCause().getMessage().contains("Invalid audience restriction")); } + @Test + public void testMissingAudienceAcceptedByDefault() throws Exception { + JwtAccessTokenValidator validator = new JwtAccessTokenValidator(); + validator.setJwsVerifier(new HmacJwsSignatureVerifier(SIGNING_KEY, SignatureAlgorithm.HS256)); + + String jwt = createSignedToken(SIGNING_KEY, "signed-client", 3600); + MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); + setThreadLocalMessage(new MessageImpl()); + + AccessTokenValidation result = validator.validateAccessToken( + mock(MessageContext.class), "Bearer", jwt, params); + + assertNotNull(result); + assertTrue(result.isInitialValidationSuccessful()); + } + + @Test + public void testMissingAudienceRejectedWhenRequired() throws Exception { + JwtAccessTokenValidator validator = new JwtAccessTokenValidator(); + validator.setJwsVerifier(new HmacJwsSignatureVerifier(SIGNING_KEY, SignatureAlgorithm.HS256)); + validator.setRequireAudience(true); + + String jwt = createSignedToken(SIGNING_KEY, "signed-client", 3600); + MultivaluedMap<String, String> params = new MultivaluedHashMap<>(); + setThreadLocalMessage(new MessageImpl()); + + OAuthServiceException ex = assertThrows(OAuthServiceException.class, () -> + validator.validateAccessToken(mock(MessageContext.class), "Bearer", jwt, params)); + + assertNotNull(ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("Invalid audience restriction")); + } + private static String createSignedToken(String key, String clientId, long expiresInSeconds) { return createSignedToken(key, clientId, expiresInSeconds, 0, null, null, null); } diff --git a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilterTest.java b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilterTest.java index d52a148f8a4..69f82b6ad82 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilterTest.java +++ b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilterTest.java @@ -22,9 +22,18 @@ import java.lang.reflect.Field; import java.util.Collections; import jakarta.ws.rs.NotAuthorizedException; +import org.apache.cxf.jaxrs.ext.MessageContext; 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.jwa.SignatureAlgorithm; +import org.apache.cxf.rs.security.jose.jws.HmacJwsSignatureProvider; +import org.apache.cxf.rs.security.jose.jws.HmacJwsSignatureVerifier; +import org.apache.cxf.rs.security.jose.jws.JwsJwtCompactProducer; +import org.apache.cxf.rs.security.jose.jwt.JwtClaims; +import org.apache.cxf.rs.security.jose.jwt.JwtConstants; +import org.apache.cxf.rs.security.jose.jwt.JwtToken; +import org.apache.cxf.rs.security.oauth2.common.OAuthContext; import org.junit.After; import org.junit.Test; @@ -32,9 +41,12 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; public class OAuthRequestFilterTest { + private static final String SIGNING_KEY = "AyM1SysPpbyDfgZld3umj1qzKObwVMkoq2QjvA6P5f8"; + @After public void clearCurrentMessage() throws Exception { setThreadLocalMessage(null); @@ -120,6 +132,89 @@ public class OAuthRequestFilterTest { assertEquals("/api/read", filter.validateAudiences(Collections.singletonList("/api/read"))); } + @Test + public void testValidateAudiencesRejectsMissingAudiencesWhenAudienceConfigured() { + OAuthRequestFilter filter = new OAuthRequestFilter(); + filter.setAudience("https://rs.example/api"); + + assertThrows(NotAuthorizedException.class, () -> filter.validateAudiences(null)); + assertThrows(NotAuthorizedException.class, () -> filter.validateAudiences(Collections.emptyList())); + assertEquals("https://rs.example/api", + filter.validateAudiences(Collections.singletonList("https://rs.example/api"))); + } + + @Test + public void testConfiguredAudienceIsUsedByJwtAccessTokenValidator() throws Exception { + Message message = new MessageImpl(); + message.put(Message.REQUEST_URL, "https://rs.example/orders"); + setThreadLocalMessage(message); + + String jwt = createSignedToken("api://orders"); + OAuthRequestFilter filter = createJwtFilter(jwt); + filter.setAudience("api://orders"); + + filter.validateRequest(message); + + assertEquals("api://orders", message.get(JwtConstants.EXPECTED_CLAIM_AUDIENCE)); + assertEquals("api://orders", message.getContent(OAuthContext.class).getTokenAudience()); + } + + @Test + public void testConfiguredAudienceRejectsJwtForOtherAudience() throws Exception { + Message message = new MessageImpl(); + message.put(Message.REQUEST_URL, "https://rs.example/orders"); + setThreadLocalMessage(message); + + String jwt = createSignedToken("api://other"); + OAuthRequestFilter filter = createJwtFilter(jwt); + filter.setAudience("api://orders"); + + assertThrows(NotAuthorizedException.class, () -> filter.validateRequest(message)); + } + + @Test + public void testConfiguredAudienceDoesNotOverrideExpectedAudience() throws Exception { + Message message = new MessageImpl(); + message.put(Message.REQUEST_URL, "https://rs.example/orders"); + message.put(JwtConstants.EXPECTED_CLAIM_AUDIENCE, "api://preset"); + setThreadLocalMessage(message); + + String jwt = createSignedToken("api://orders"); + OAuthRequestFilter filter = createJwtFilter(jwt); + filter.setAudience("api://orders"); + + assertThrows(NotAuthorizedException.class, () -> filter.validateRequest(message)); + assertEquals("api://preset", message.get(JwtConstants.EXPECTED_CLAIM_AUDIENCE)); + } + + private static OAuthRequestFilter createJwtFilter(String jwt) { + JwtAccessTokenValidator validator = new JwtAccessTokenValidator(); + validator.setJwsVerifier(new HmacJwsSignatureVerifier(SIGNING_KEY, SignatureAlgorithm.HS256)); + + OAuthRequestFilter filter = new OAuthRequestFilter() { + @Override + protected String[] getAuthorizationParts(Message m) { + return new String[] {"Bearer", jwt}; + } + }; + filter.setTokenValidator(validator); + filter.setMessageContext(mock(MessageContext.class)); + return filter; + } + + private static String createSignedToken(String audience) { + long now = System.currentTimeMillis() / 1000; + JwtClaims claims = new JwtClaims(); + claims.setIssuedAt(now); + claims.setExpiryTime(now + 3600); + claims.setIssuer("https://as.example"); + claims.setClaim("client_id", "client"); + claims.setAudience(audience); + + JwsJwtCompactProducer producer = new JwsJwtCompactProducer(new JwtToken(claims)); + return producer.signWith(new HmacJwsSignatureProvider(SIGNING_KEY, SignatureAlgorithm.HS256)); + } + private static void setThreadLocalMessage(Message message) throws Exception { Field f = PhaseInterceptorChain.class.getDeclaredField("CURRENT_MESSAGE"); f.setAccessible(true);
