This is an automated email from the ASF dual-hosted git repository. btellier pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit 3e9a98b148a7500a9b0f877be90037afd6a6921d Author: Amichai Rothman <amich...@amichais.net> AuthorDate: Wed Jun 25 19:33:42 2025 +0300 [UPGRADE] upgrade jjwt to 0.12.6 --- pom.xml | 2 +- .../apache/james/jwt/DefaultPublicKeyProvider.java | 9 ++ .../apache/james/jwt/JwksPublicKeyProvider.java | 21 ++--- .../org/apache/james/jwt/JwtTokenVerifier.java | 105 +++++++++++++++------ .../org/apache/james/jwt/OidcJwtTokenVerifier.java | 30 +----- .../org/apache/james/jwt/PublicKeyProvider.java | 17 ++++ .../james/jwt/JwksPublicKeyProviderTest.java | 16 ++-- .../apache/james/jwt/OidcJwtTokenVerifierTest.java | 40 +++++--- 8 files changed, 144 insertions(+), 96 deletions(-) diff --git a/pom.xml b/pom.xml index 6d1f31ae8b..dc7bd4b7ed 100644 --- a/pom.xml +++ b/pom.xml @@ -651,7 +651,7 @@ <jackson.databind.version>2.19.1</jackson.databind.version> <feign.version>13.6</feign.version> <feign-form.version>3.8.0</feign-form.version> - <jjwt.version>0.11.5</jjwt.version> + <jjwt.version>0.12.6</jjwt.version> <metrics.version>4.2.32</metrics.version> <testcontainers.version>1.21.1</testcontainers.version> <es-reporter.version>6.0.0-RC3</es-reporter.version> diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/DefaultPublicKeyProvider.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/DefaultPublicKeyProvider.java index 2d4ecd739c..8627b1526b 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/DefaultPublicKeyProvider.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/DefaultPublicKeyProvider.java @@ -20,6 +20,7 @@ package org.apache.james.jwt; import java.security.PublicKey; import java.util.List; +import java.util.Optional; import com.google.common.collect.ImmutableList; @@ -45,4 +46,12 @@ public class DefaultPublicKeyProvider implements PublicKeyProvider { return keys; } + @Override + public Optional<PublicKey> get(String kid) throws MissingOrInvalidKeyException { + // TODO: pick a simple or standard way of calculating a unique kid for each public key + // that can be calculated by the user when generating the JWT + // and calculated or looked up here for comparison. + return Optional.empty(); + } + } diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwksPublicKeyProvider.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwksPublicKeyProvider.java index 0074981c9f..a9ca63fe53 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwksPublicKeyProvider.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwksPublicKeyProvider.java @@ -38,35 +38,28 @@ import com.google.common.collect.ImmutableList; public class JwksPublicKeyProvider implements PublicKeyProvider { private static final Logger LOGGER = LoggerFactory.getLogger(JwksPublicKeyProvider.class); - public static JwksPublicKeyProvider of(URL jwksURL, String kid) { - return new JwksPublicKeyProvider(jwksURL, Optional.of(kid)); - } - public static JwksPublicKeyProvider of(URL jwksURL) { - return new JwksPublicKeyProvider(jwksURL, Optional.empty()); + return new JwksPublicKeyProvider(jwksURL); } private final UrlJwkProvider jwkProvider; - private final Optional<String> kid; - private JwksPublicKeyProvider(URL jwksURL, Optional<String> kid) { + private JwksPublicKeyProvider(URL jwksURL) { Preconditions.checkNotNull(jwksURL); - Preconditions.checkNotNull(kid); this.jwkProvider = new UrlJwkProvider(jwksURL); - this.kid = kid; } @Override public List<PublicKey> get() throws MissingOrInvalidKeyException { - return kid.map(this::get) - .orElse(getAllFromProvider()); + return getAllFromProvider(); } - public List<PublicKey> get(String kid) throws MissingOrInvalidKeyException { + @Override + public Optional<PublicKey> get(String kid) throws MissingOrInvalidKeyException { try { - return ImmutableList.of(jwkProvider.get(kid).getPublicKey()); + return Optional.of(jwkProvider.get(kid).getPublicKey()); } catch (SigningKeyNotFoundException notFoundException) { - return ImmutableList.of(); + return Optional.empty(); } catch (JwkException e) { LOGGER.error("Can't get publicKeys has kid = {} from jwksURL.", kid, e); throw new MissingOrInvalidKeyException(); diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenVerifier.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenVerifier.java index 878a8a6c76..ae197d8e68 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenVerifier.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/JwtTokenVerifier.java @@ -18,43 +18,75 @@ ****************************************************************/ package org.apache.james.jwt; -import java.security.PublicKey; + +import java.io.InputStream; +import java.io.OutputStream; +import java.security.Key; import java.util.List; +import java.util.Objects; import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import io.jsonwebtoken.Claims; -import io.jsonwebtoken.CompressionCodecResolver; import io.jsonwebtoken.Jws; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.Locator; import io.jsonwebtoken.MalformedJwtException; -import io.jsonwebtoken.impl.compression.DefaultCompressionCodecResolver; +import io.jsonwebtoken.io.CompressionAlgorithm; public class JwtTokenVerifier { - private static final CompressionCodecResolver DEFAULT_COMPRESSION_CODEC_RESOLVER = new DefaultCompressionCodecResolver(); - private static final CompressionCodecResolver SECURE_COMPRESSION_CODEC_RESOLVER = header -> { - if (Optional.ofNullable(header.getCompressionAlgorithm()).isPresent()) { - throw new RuntimeException("Rejecting a ZIP JWT. Usage of ZIPPED JWT can result in " + - "excessive memory usage with malicious JWT tokens. To activate support for ZIPPed" + - "JWT please run James with the -Djames.jwt.zip.allow=true system property."); - } - return DEFAULT_COMPRESSION_CODEC_RESOLVER.resolveCompressionCodec(header); - }; - private static final boolean allowZipJWT = Optional.ofNullable(System.getProperty("james.jwt.zip.allow")) + static boolean allowZipJWT = Optional.ofNullable(System.getProperty("james.jwt.zip.allow")) .map(Boolean::parseBoolean) .orElse(false); - @VisibleForTesting - static CompressionCodecResolver CONFIGURED_COMPRESSION_CODEC_RESOLVER = Optional.of(allowZipJWT) - .filter(b -> b) - .map(any -> DEFAULT_COMPRESSION_CODEC_RESOLVER) - .orElse(SECURE_COMPRESSION_CODEC_RESOLVER); + + /** + * A CompressionAlgorithm that delegates to another compression algorithm + * if compression is allowed, or throws exceptions if not. + */ + private static class SecureCompressionAlgorithm implements CompressionAlgorithm { + String id; + CompressionAlgorithm delegate; + + public SecureCompressionAlgorithm(String id, CompressionAlgorithm delegate) { + this.id = id; + this.delegate = delegate; + } + + private void validate() { + if (!allowZipJWT) { + throw new RuntimeException("Rejecting a ZIP JWT. Usage of ZIPPED JWT can result in " + + "excessive memory usage with malicious JWT tokens. To activate support for ZIPPed" + + "JWT please run James with the -Djames.jwt.zip.allow=true system property."); + } + } + + @Override + public String getId() { + return id; + } + + @Override + public OutputStream compress(OutputStream out) { + validate(); + return delegate.compress(out); + } + + @Override + public InputStream decompress(InputStream in) { + validate(); + return delegate.decompress(in); + } + } + + // wrap all supported compression algorithms with 'secure' ones that throw exception if disabled + private static final List<SecureCompressionAlgorithm> SECURE_COMPRESSION_ALGORITHMS = + Jwts.ZIP.get().values().stream().map(c -> new SecureCompressionAlgorithm(c.getId(), c)).toList(); public interface Factory { JwtTokenVerifier create(); @@ -67,12 +99,19 @@ public class JwtTokenVerifier { private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenVerifier.class); + private final JwtParser kidJwtParser; private final List<JwtParser> jwtParsers; public JwtTokenVerifier(PublicKeyProvider pubKeyProvider) { + // one parser that performs key lookup by kid + this.kidJwtParser = toImmutableJwtParser(jwtHeaders -> { + String kid = Objects.requireNonNull((String)jwtHeaders.get("kid")); + return pubKeyProvider.get(kid).orElse(null); + }); + // a list of parsers, one for each of the provider's keys this.jwtParsers = pubKeyProvider.get() .stream() - .map(this::toImmutableJwtParser) + .map(key -> toImmutableJwtParser(jwtHeaders -> key)) .collect(ImmutableList.toImmutableList()); } @@ -82,22 +121,28 @@ public class JwtTokenVerifier { } public <T> Optional<T> verifyAndExtractClaim(String token, String claimName, Class<T> returnType) { - return jwtParsers.stream() - .flatMap(parser -> verifyAndExtractClaim(token, claimName, returnType, parser).stream()) - .findFirst(); + try { + // if the token contains a kid, verify only with the corresponding key (or fail) + return verifyAndExtractClaim(token, claimName, returnType, kidJwtParser); + } catch (NullPointerException npe) { // our own key locator throws NPE when there is no kid + // if token does not specify kid, fallback to trying all keys + return jwtParsers.stream() + .flatMap(parser -> verifyAndExtractClaim(token, claimName, returnType, parser).stream()) + .findFirst(); + } } private <T> Optional<T> verifyAndExtractClaim(String token, String claimName, Class<T> returnType, JwtParser parser) { try { - Jws<Claims> jws = parser.parseClaimsJws(token); + Jws<Claims> jws = parser.parseSignedClaims(token); T claim = jws - .getBody() + .getPayload() .get(claimName, returnType); if (claim == null) { throw new MalformedJwtException("'" + claimName + "' field in token is mandatory"); } return Optional.of(claim); - } catch (JwtException e) { + } catch (JwtException e) { // also if kid was given but our locator didn't find the corresponding key LOGGER.info("Failed Jwt verification", e); return Optional.empty(); } @@ -114,10 +159,10 @@ public class JwtTokenVerifier { } } - private JwtParser toImmutableJwtParser(PublicKey publicKey) { - return Jwts.parserBuilder() - .setSigningKey(publicKey) - .setCompressionCodecResolver(CONFIGURED_COMPRESSION_CODEC_RESOLVER) + private JwtParser toImmutableJwtParser(Locator<Key> keyLocator) { + return Jwts.parser() + .keyLocator(keyLocator) + .zip().add(SECURE_COMPRESSION_ALGORITHMS).and() .build(); } } diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java index 35ba364dca..63124ca253 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java @@ -26,40 +26,14 @@ import org.apache.james.jwt.introspection.IntrospectionEndpoint; import org.apache.james.jwt.introspection.TokenIntrospectionResponse; import org.reactivestreams.Publisher; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Header; -import io.jsonwebtoken.Jwt; -import io.jsonwebtoken.JwtException; -import io.jsonwebtoken.Jwts; import reactor.core.publisher.Mono; public class OidcJwtTokenVerifier { public static final CheckTokenClient CHECK_TOKEN_CLIENT = new DefaultCheckTokenClient(); public static Optional<String> verifySignatureAndExtractClaim(String jwtToken, URL jwksURL, String claimName) { - Optional<String> unverifiedClaim = getClaimWithoutSignatureVerification(jwtToken, "kid"); - PublicKeyProvider jwksPublicKeyProvider = unverifiedClaim - .map(kidValue -> JwksPublicKeyProvider.of(jwksURL, kidValue)) - .orElse(JwksPublicKeyProvider.of(jwksURL)); - return new JwtTokenVerifier(jwksPublicKeyProvider).verifyAndExtractClaim(jwtToken, claimName, String.class); - } - - public static <T> Optional<T> getClaimWithoutSignatureVerification(String token, String claimName) { - int signatureIndex = token.lastIndexOf('.'); - if (signatureIndex <= 0) { - return Optional.empty(); - } - String nonSignedToken = token.substring(0, signatureIndex + 1); - try { - Jwt<Header, Claims> headerClaims = Jwts.parserBuilder().build().parseClaimsJwt(nonSignedToken); - T claim = (T) headerClaims.getHeader().get(claimName); - if (claim == null) { - return Optional.empty(); - } - return Optional.of(claim); - } catch (JwtException e) { - return Optional.empty(); - } + return new JwtTokenVerifier(JwksPublicKeyProvider.of(jwksURL)) + .verifyAndExtractClaim(jwtToken, claimName, String.class); } public static Publisher<String> verifyWithIntrospection(String jwtToken, URL jwksURL, String claimName, IntrospectionEndpoint introspectionEndpoint) { diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/PublicKeyProvider.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/PublicKeyProvider.java index c87f25bfe2..b5cd772771 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/PublicKeyProvider.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/PublicKeyProvider.java @@ -21,7 +21,24 @@ package org.apache.james.jwt; import java.security.PublicKey; import java.util.List; +import java.util.Optional; public interface PublicKeyProvider { + + /** + * Returns all keys managed by this provider. + * + * @return all keys managed by this provider + * @throws MissingOrInvalidKeyException if an error occurred while getting the keys + */ List<PublicKey> get() throws MissingOrInvalidKeyException; + + /** + * Returns the key corresponding to the given kid. + * + * @param kid the kid value + * @return the key corresponding to the given kid, or empty if not found + * @throws MissingOrInvalidKeyException if an error occurred while getting the key + */ + Optional<PublicKey> get(String kid) throws MissingOrInvalidKeyException; } diff --git a/server/protocols/jwt/src/test/java/org/apache/james/jwt/JwksPublicKeyProviderTest.java b/server/protocols/jwt/src/test/java/org/apache/james/jwt/JwksPublicKeyProviderTest.java index 68da3f5845..7351d4bcf3 100644 --- a/server/protocols/jwt/src/test/java/org/apache/james/jwt/JwksPublicKeyProviderTest.java +++ b/server/protocols/jwt/src/test/java/org/apache/james/jwt/JwksPublicKeyProviderTest.java @@ -29,7 +29,6 @@ import java.net.URL; import java.nio.charset.StandardCharsets; import java.security.PublicKey; import java.security.interfaces.RSAPublicKey; -import java.util.List; import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.BeforeEach; @@ -93,18 +92,17 @@ class JwksPublicKeyProviderTest { @Test void getShouldSuccessWhenKeyProvided() { - PublicKeyProvider testee = JwksPublicKeyProvider.of(getJwksURL(), "wu-9VZEr0gHF986PYPVzvU-5IP1q26EzzQVK_sjG29Q"); - List<PublicKey> publicKeys = testee.get(); + PublicKeyProvider testee = JwksPublicKeyProvider.of(getJwksURL()); + PublicKey publicKey = testee.get("wu-9VZEr0gHF986PYPVzvU-5IP1q26EzzQVK_sjG29Q").get(); SoftAssertions.assertSoftly(softly -> { - softly.assertThat(publicKeys).hasSize(1); - softly.assertThat(publicKeys.get(0)).isInstanceOf(RSAPublicKey.class); + softly.assertThat(publicKey).isInstanceOf(RSAPublicKey.class); }); } @Test void getShouldReturnEmptyWhenKeyNotProvided() { - PublicKeyProvider testee = JwksPublicKeyProvider.of(getJwksURL(), "notfound"); - assertThat(testee.get()).isEmpty(); + PublicKeyProvider testee = JwksPublicKeyProvider.of(getJwksURL()); + assertThat(testee.get("notfound")).isEmpty(); } @Test @@ -114,8 +112,8 @@ class JwksPublicKeyProviderTest { .respond(HttpResponse.response().withStatusCode(200) .withBody("invalid body", StandardCharsets.UTF_8)); - PublicKeyProvider testee = JwksPublicKeyProvider.of(new URI(String.format("http://127.0.0.1:%s/invalid", mockServer.getLocalPort())).toURL(), - "wu-9VZEr0gHF986PYPVzvU-5IP1q26EzzQVK_sjG29Q"); + PublicKeyProvider testee = JwksPublicKeyProvider.of( + new URI(String.format("http://127.0.0.1:%s/invalid", mockServer.getLocalPort())).toURL()); assertThatThrownBy(testee::get) .isInstanceOf(MissingOrInvalidKeyException.class); } diff --git a/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwtTokenVerifierTest.java b/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwtTokenVerifierTest.java index 9446aa69b5..0c9c1f597a 100644 --- a/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwtTokenVerifierTest.java +++ b/server/protocols/jwt/src/test/java/org/apache/james/jwt/OidcJwtTokenVerifierTest.java @@ -30,6 +30,11 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; import java.util.Optional; import org.apache.james.jwt.introspection.IntrospectionEndpoint; @@ -43,10 +48,8 @@ import org.mockserver.integration.ClientAndServer; import org.mockserver.model.HttpRequest; import org.mockserver.model.HttpResponse; -import io.jsonwebtoken.CompressionCodecs; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.impl.compression.DefaultCompressionCodecResolver; +import io.jsonwebtoken.io.Decoders; import reactor.core.publisher.Mono; class OidcJwtTokenVerifierTest { @@ -93,12 +96,18 @@ class OidcJwtTokenVerifierTest { }); } + private static PrivateKey toPrivateKey(String base64Key) throws NoSuchAlgorithmException, InvalidKeySpecException { + byte[] keyBytes = Decoders.BASE64.decode(base64Key.replace("\n", "")); + KeyFactory kf = KeyFactory.getInstance("RSA"); // or "EC" or whatever + return kf.generatePrivate(new PKCS8EncodedKeySpec(keyBytes)); + } + @Test - void shouldRejectZippedJWTByDefault() { + void shouldRejectZippedJWTByDefault() throws NoSuchAlgorithmException, InvalidKeySpecException { String jws = Jwts.builder() .claim("kid", "a".repeat(100)) - .compressWith(CompressionCodecs.DEFLATE) - .signWith(SignatureAlgorithm.HS256, OidcTokenFixture.PRIVATE_KEY_BASE64.replace("\n", "")) + .compressWith(Jwts.ZIP.DEF) + .signWith(toPrivateKey(OidcTokenFixture.PRIVATE_KEY_BASE64), Jwts.SIG.RS256) .compact(); assertThatThrownBy(() -> OidcJwtTokenVerifier.verifySignatureAndExtractClaim(jws, getJwksURL(), "kid")) @@ -107,17 +116,21 @@ class OidcJwtTokenVerifierTest { } @Test - void shouldAcceptZippedJWTWhenConfigured() { + void shouldAcceptZippedJWTWhenConfigured() throws NoSuchAlgorithmException, InvalidKeySpecException { String jws = Jwts.builder() .claim("kid", "a".repeat(100)) - .compressWith(CompressionCodecs.DEFLATE) - .signWith(SignatureAlgorithm.HS256, OidcTokenFixture.PRIVATE_KEY_BASE64.replace("\n", "")) + .compressWith(Jwts.ZIP.DEF) + .signWith(toPrivateKey(OidcTokenFixture.PRIVATE_KEY_BASE64), Jwts.SIG.RS256) .compact(); - JwtTokenVerifier.CONFIGURED_COMPRESSION_CODEC_RESOLVER = new DefaultCompressionCodecResolver(); - - assertThatCode(() -> OidcJwtTokenVerifier.verifySignatureAndExtractClaim(jws, getJwksURL(), "kid")) - .doesNotThrowAnyException(); + boolean prev = JwtTokenVerifier.allowZipJWT; + JwtTokenVerifier.allowZipJWT = true; + try { + assertThatCode(() -> OidcJwtTokenVerifier.verifySignatureAndExtractClaim(jws, getJwksURL(), "kid")) + .doesNotThrowAnyException(); + } finally { + JwtTokenVerifier.allowZipJWT = prev; + } } @Test @@ -132,7 +145,6 @@ class OidcJwtTokenVerifierTest { .isEmpty(); } - @Test void verifyAndClaimShouldReturnEmptyWhenInvalidToken() { assertThat(OidcJwtTokenVerifier.verifySignatureAndExtractClaim(OidcTokenFixture.INVALID_TOKEN, getJwksURL(), "email_address")) --------------------------------------------------------------------- To unsubscribe, e-mail: notifications-unsubscr...@james.apache.org For additional commands, e-mail: notifications-h...@james.apache.org