From 81e2fd2fe8a561162bd33a7914e85c764b34cf15 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Tue, 25 Feb 2025 13:53:34 -0700 Subject: [PATCH] Add Type Validation Closes gh-16672 --- .../security/oauth2/jwt/JwtTypeValidator.java | 79 ++++++++ .../security/oauth2/jwt/NimbusJwtDecoder.java | 177 ++++++++++++++++++ .../oauth2/jwt/JwtTypeValidatorTests.java | 47 +++++ .../oauth2/jwt/NimbusJwtDecoderTests.java | 22 +++ 4 files changed, 325 insertions(+) create mode 100644 oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtTypeValidator.java create mode 100644 oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtTypeValidatorTests.java diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtTypeValidator.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtTypeValidator.java new file mode 100644 index 0000000000..f138a84c51 --- /dev/null +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtTypeValidator.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.security.oauth2.jwt; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * A validator for the {@code typ} header. Specifically for indicating the header values + * that a given {@link JwtDecoder} will support. + * + * @author Josh Cummings + * @since 6.5 + */ +public final class JwtTypeValidator implements OAuth2TokenValidator { + + private Collection validTypes; + + private boolean allowEmpty; + + public JwtTypeValidator(Collection validTypes) { + Assert.notEmpty(validTypes, "validTypes cannot be empty"); + this.validTypes = new ArrayList<>(validTypes); + } + + /** + * Require that the {@code typ} header be {@code JWT} or absent + */ + public static JwtTypeValidator jwt() { + JwtTypeValidator validator = new JwtTypeValidator(List.of("JWT")); + validator.setAllowEmpty(true); + return validator; + } + + /** + * Whether to allow the {@code typ} header to be empty. The default value is + * {@code false} + */ + public void setAllowEmpty(boolean allowEmpty) { + this.allowEmpty = allowEmpty; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt token) { + String typ = (String) token.getHeaders().get(JoseHeaderNames.TYP); + if (this.allowEmpty && !StringUtils.hasText(typ)) { + return OAuth2TokenValidatorResult.success(); + } + if (this.validTypes.contains(typ)) { + return OAuth2TokenValidatorResult.success(); + } + return OAuth2TokenValidatorResult.failure(new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, + "the given typ value needs to be one of " + this.validTypes, + "https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.9")); + } + +} diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.java index df0239ebfe..1ff93c837d 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.java @@ -33,6 +33,7 @@ import java.util.function.Function; import javax.crypto.SecretKey; import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.KeySourceException; import com.nimbusds.jose.RemoteKeySourceException; @@ -41,6 +42,8 @@ import com.nimbusds.jose.jwk.source.JWKSetCacheRefreshEvaluator; import com.nimbusds.jose.jwk.source.JWKSetSource; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; +import com.nimbusds.jose.proc.JOSEObjectTypeVerifier; import com.nimbusds.jose.proc.JWSKeySelector; import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.SecurityContext; @@ -265,11 +268,20 @@ public final class NimbusJwtDecoder implements JwtDecoder { */ public static final class JwkSetUriJwtDecoderBuilder { + private static final JOSEObjectTypeVerifier JWT_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>( + JOSEObjectType.JWT, null); + + private static final JOSEObjectTypeVerifier NO_TYPE_VERIFIER = (header, context) -> { + }; + private Function jwkSetUri; private Function, Set> defaultAlgorithms = (source) -> Set .of(JWSAlgorithm.RS256); + private JOSEObjectTypeVerifier typeVerifier = new DefaultJOSEObjectTypeVerifier<>( + JOSEObjectType.JWT, null); + private Set signatureAlgorithms = new HashSet<>(); private RestOperations restOperations = new RestTemplate(); @@ -295,6 +307,54 @@ public final class NimbusJwtDecoder implements JwtDecoder { }; } + /** + * Whether to use Nimbus's typ header verification. This is {@code true} by + * default, however it may change to {@code false} in a future major release. + * + *

+ * By turning off this feature, {@link NimbusJwtDecoder} expects applications to + * check the {@code typ} header themselves in order to determine what kind of + * validation is needed + *

+ * + *

+ * This is done for you when you use {@link JwtValidators} to construct a + * validator. + * + *

+ * That means that this: + * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build(); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer); + * + * + *

+ * Is equivalent to this: + * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer) + * .validateType(false) + * .build(); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer); + * + * + *

+ * The difference is that by setting this to {@code false}, it allows you to + * provide validation by type, like for {@code at+jwt}: + * + * + * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer) + * .validateType(false) + * .build(); + * jwtDecoder.setJwtValidator(new MyAtJwtValidator()); + * + * @param shouldValidateTypHeader whether Nimbus should validate the typ header or + * not + * @return a {@link JwkSetUriJwtDecoderBuilder} for further configurations + * @since 6.5 + */ + public JwkSetUriJwtDecoderBuilder validateType(boolean shouldValidateTypHeader) { + this.typeVerifier = shouldValidateTypHeader ? JWT_TYPE_VERIFIER : NO_TYPE_VERIFIER; + return this; + } + /** * Append the given signing * processor() { JWKSource jwkSource = jwkSource(); ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); + jwtProcessor.setJWSTypeVerifier(this.typeVerifier); jwtProcessor.setJWSKeySelector(jwsKeySelector(jwkSource)); // Spring Security validates the claim set independent from Nimbus jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> { @@ -481,8 +542,17 @@ public final class NimbusJwtDecoder implements JwtDecoder { */ public static final class PublicKeyJwtDecoderBuilder { + private static final JOSEObjectTypeVerifier JWT_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>( + JOSEObjectType.JWT, null); + + private static final JOSEObjectTypeVerifier NO_TYPE_VERIFIER = (header, context) -> { + }; + private JWSAlgorithm jwsAlgorithm; + private JOSEObjectTypeVerifier typeVerifier = new DefaultJOSEObjectTypeVerifier<>( + JOSEObjectType.JWT, null); + private RSAPublicKey key; private Consumer> jwtProcessorCustomizer; @@ -495,6 +565,54 @@ public final class NimbusJwtDecoder implements JwtDecoder { }; } + /** + * Whether to use Nimbus's typ header verification. This is {@code true} by + * default, however it may change to {@code false} in a future major release. + * + *

+ * By turning off this feature, {@link NimbusJwtDecoder} expects applications to + * check the {@code typ} header themselves in order to determine what kind of + * validation is needed + *

+ * + *

+ * This is done for you when you use {@link JwtValidators} to construct a + * validator. + * + *

+ * That means that this: + * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build(); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer); + * + * + *

+ * Is equivalent to this: + * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer) + * .validateType(false) + * .build(); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer); + * + * + *

+ * The difference is that by setting this to {@code false}, it allows you to + * provide validation by type, like for {@code at+jwt}: + * + * + * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer) + * .validateType(false) + * .build(); + * jwtDecoder.setJwtValidator(new MyAtJwtValidator()); + * + * @param shouldValidateTypHeader whether Nimbus should validate the typ header or + * not + * @return a {@link JwkSetUriJwtDecoderBuilder} for further configurations + * @since 6.5 + */ + public PublicKeyJwtDecoderBuilder validateType(boolean shouldValidateTypHeader) { + this.typeVerifier = shouldValidateTypHeader ? JWT_TYPE_VERIFIER : NO_TYPE_VERIFIER; + return this; + } + /** * Use the given signing * jwsKeySelector = new SingleKeyJWSKeySelector<>(this.jwsAlgorithm, this.key); DefaultJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); + jwtProcessor.setJWSTypeVerifier(this.typeVerifier); jwtProcessor.setJWSKeySelector(jwsKeySelector); // Spring Security validates the claim set independent from Nimbus jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> { @@ -557,10 +676,19 @@ public final class NimbusJwtDecoder implements JwtDecoder { */ public static final class SecretKeyJwtDecoderBuilder { + private static final JOSEObjectTypeVerifier JWT_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>( + JOSEObjectType.JWT, null); + + private static final JOSEObjectTypeVerifier NO_TYPE_VERIFIER = (header, context) -> { + }; + private final SecretKey secretKey; private JWSAlgorithm jwsAlgorithm = JWSAlgorithm.HS256; + private JOSEObjectTypeVerifier typeVerifier = new DefaultJOSEObjectTypeVerifier<>( + JOSEObjectType.JWT, null); + private Consumer> jwtProcessorCustomizer; private SecretKeyJwtDecoderBuilder(SecretKey secretKey) { @@ -570,6 +698,54 @@ public final class NimbusJwtDecoder implements JwtDecoder { }; } + /** + * Whether to use Nimbus's typ header verification. This is {@code true} by + * default, however it may change to {@code false} in a future major release. + * + *

+ * By turning off this feature, {@link NimbusJwtDecoder} expects applications to + * check the {@code typ} header themselves in order to determine what kind of + * validation is needed + *

+ * + *

+ * This is done for you when you use {@link JwtValidators} to construct a + * validator. + * + *

+ * That means that this: + * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build(); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer); + * + * + *

+ * Is equivalent to this: + * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer) + * .validateType(false) + * .build(); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer); + * + * + *

+ * The difference is that by setting this to {@code false}, it allows you to + * provide validation by type, like for {@code at+jwt}: + * + * + * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer) + * .validateType(false) + * .build(); + * jwtDecoder.setJwtValidator(new MyAtJwtValidator()); + * + * @param shouldValidateTypHeader whether Nimbus should validate the typ header or + * not + * @return a {@link JwkSetUriJwtDecoderBuilder} for further configurations + * @since 6.5 + */ + public SecretKeyJwtDecoderBuilder validateType(boolean shouldValidateTypHeader) { + this.typeVerifier = shouldValidateTypHeader ? JWT_TYPE_VERIFIER : NO_TYPE_VERIFIER; + return this; + } + /** * Use the given * jwtProcessor = new DefaultJWTProcessor<>(); jwtProcessor.setJWSKeySelector(jwsKeySelector); + jwtProcessor.setJWSTypeVerifier(this.typeVerifier); // Spring Security validates the claim set independent from Nimbus jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> { }); diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtTypeValidatorTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtTypeValidatorTests.java new file mode 100644 index 0000000000..b76aaeff6d --- /dev/null +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtTypeValidatorTests.java @@ -0,0 +1,47 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.security.oauth2.jwt; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwtTypeValidatorTests { + + @Test + void constructorWhenJwtThenRequiresJwtOrEmpty() { + Jwt.Builder jwt = TestJwts.jwt(); + JwtTypeValidator validator = JwtTypeValidator.jwt(); + assertThat(validator.validate(jwt.build()).hasErrors()).isFalse(); + jwt.header(JoseHeaderNames.TYP, "JWT"); + assertThat(validator.validate(jwt.build()).hasErrors()).isFalse(); + jwt.header(JoseHeaderNames.TYP, "at+jwt"); + assertThat(validator.validate(jwt.build()).hasErrors()).isTrue(); + } + + @Test + void constructorWhenCustomThenEnforces() { + Jwt.Builder jwt = TestJwts.jwt(); + JwtTypeValidator validator = new JwtTypeValidator("JOSE"); + assertThat(validator.validate(jwt.build()).hasErrors()).isTrue(); + jwt.header(JoseHeaderNames.TYP, "JWT"); + assertThat(validator.validate(jwt.build()).hasErrors()).isTrue(); + jwt.header(JoseHeaderNames.TYP, "JOSE"); + assertThat(validator.validate(jwt.build()).hasErrors()).isFalse(); + } + +} diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderTests.java index d638795df9..6934170768 100644 --- a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderTests.java +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderTests.java @@ -832,6 +832,28 @@ public class NimbusJwtDecoderTests { // @formatter:on } + @Test + public void decodeWhenPublicKeyValidateTypeFalseThenSkipsNimbusTypeValidation() throws Exception { + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(TestKeys.DEFAULT_PUBLIC_KEY) + .validateType(false) + .build(); + RSAPrivateKey privateKey = TestKeys.DEFAULT_PRIVATE_KEY; + SignedJWT jwt = signedJwt(privateKey, + new JWSHeader.Builder(JWSAlgorithm.RS256).type(JOSEObjectType.JOSE).build(), + new JWTClaimsSet.Builder().subject("subject").build()); + jwtDecoder.decode(jwt.serialize()); + } + + @Test + public void decodeWhenSecretKeyValidateTypeFalseThenSkipsNimbusTypeValidation() throws Exception { + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withSecretKey(TestKeys.DEFAULT_SECRET_KEY) + .validateType(false) + .build(); + SignedJWT jwt = signedJwt(TestKeys.DEFAULT_SECRET_KEY, MacAlgorithm.HS256, + new JWTClaimsSet.Builder().subject("subject").build()); + jwtDecoder.decode(jwt.serialize()); + } + private RSAPublicKey key() throws InvalidKeySpecException { byte[] decoded = Base64.getDecoder().decode(VERIFY_KEY.getBytes()); EncodedKeySpec spec = new X509EncodedKeySpec(decoded);