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";