This is an automated email from the ASF dual-hosted git repository.
coheigea pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cxf.git
The following commit(s) were added to refs/heads/main by this push:
new 996e1191217 Adding a flag to require an audience in
JwtAccessTokenValidator (#3530)
996e1191217 is described below
commit 996e1191217cfb77daef7a4a7791bc1dfd13a343
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Wed Sep 30 17:34:54 2026 +0100
Adding a flag to require an audience in JwtAccessTokenValidator (#3530)
---
.../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);