Browse Source

Add Type Validation

Closes gh-16672
Josh Cummings 6 months ago
parent
commit
81e2fd2fe8

+ 79 - 0
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<Jwt> {
+
+	private Collection<String> validTypes;
+
+	private boolean allowEmpty;
+
+	public JwtTypeValidator(Collection<String> 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"));
+	}
+
+}

+ 177 - 0
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<SecurityContext> JWT_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>(
+				JOSEObjectType.JWT, null);
+
+		private static final JOSEObjectTypeVerifier<SecurityContext> NO_TYPE_VERIFIER = (header, context) -> {
+		};
+
 		private Function<RestOperations, String> jwkSetUri;
 
 		private Function<JWKSource<SecurityContext>, Set<JWSAlgorithm>> defaultAlgorithms = (source) -> Set
 			.of(JWSAlgorithm.RS256);
 
+		private JOSEObjectTypeVerifier<SecurityContext> typeVerifier = new DefaultJOSEObjectTypeVerifier<>(
+				JOSEObjectType.JWT, null);
+
 		private Set<SignatureAlgorithm> 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.
+		 *
+		 * <p>
+		 * 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
+		 * </p>
+		 *
+		 * <p>
+		 * This is done for you when you use {@link JwtValidators} to construct a
+		 * validator.
+		 *
+		 * <p>
+		 * That means that this: <code>
+		 *     NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
+		 *     jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer);
+		 * </code>
+		 *
+		 * <p>
+		 * Is equivalent to this: <code>
+		 *     NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer)
+		 *         .validateType(false)
+		 *         .build();
+		 *     jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer);
+		 * </code>
+		 *
+		 * <p>
+		 * The difference is that by setting this to {@code false}, it allows you to
+		 * provide validation by type, like for {@code at+jwt}:
+		 *
+		 * <code>
+		 *     NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer)
+		 *         .validateType(false)
+		 *         .build();
+		 *     jwtDecoder.setJwtValidator(new MyAtJwtValidator());
+		 * </code>
+		 * @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
 		 * <a href="https://tools.ietf.org/html/rfc7515#section-4.1.1" target=
@@ -389,6 +449,7 @@ public final class NimbusJwtDecoder implements JwtDecoder {
 		JWTProcessor<SecurityContext> processor() {
 			JWKSource<SecurityContext> jwkSource = jwkSource();
 			ConfigurableJWTProcessor<SecurityContext> 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<SecurityContext> JWT_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>(
+				JOSEObjectType.JWT, null);
+
+		private static final JOSEObjectTypeVerifier<SecurityContext> NO_TYPE_VERIFIER = (header, context) -> {
+		};
+
 		private JWSAlgorithm jwsAlgorithm;
 
+		private JOSEObjectTypeVerifier<SecurityContext> typeVerifier = new DefaultJOSEObjectTypeVerifier<>(
+				JOSEObjectType.JWT, null);
+
 		private RSAPublicKey key;
 
 		private Consumer<ConfigurableJWTProcessor<SecurityContext>> 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.
+		 *
+		 * <p>
+		 * 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
+		 * </p>
+		 *
+		 * <p>
+		 * This is done for you when you use {@link JwtValidators} to construct a
+		 * validator.
+		 *
+		 * <p>
+		 * That means that this: <code>
+		 *     NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
+		 *     jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer);
+		 * </code>
+		 *
+		 * <p>
+		 * Is equivalent to this: <code>
+		 *     NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer)
+		 *         .validateType(false)
+		 *         .build();
+		 *     jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer);
+		 * </code>
+		 *
+		 * <p>
+		 * The difference is that by setting this to {@code false}, it allows you to
+		 * provide validation by type, like for {@code at+jwt}:
+		 *
+		 * <code>
+		 *     NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer)
+		 *         .validateType(false)
+		 *         .build();
+		 *     jwtDecoder.setJwtValidator(new MyAtJwtValidator());
+		 * </code>
+		 * @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
 		 * <a href="https://tools.ietf.org/html/rfc7515#section-4.1.1" target=
@@ -533,6 +651,7 @@ public final class NimbusJwtDecoder implements JwtDecoder {
 							+ this.jwsAlgorithm + ". Please indicate one of RS256, RS384, or RS512.");
 			JWSKeySelector<SecurityContext> jwsKeySelector = new SingleKeyJWSKeySelector<>(this.jwsAlgorithm, this.key);
 			DefaultJWTProcessor<SecurityContext> 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<SecurityContext> JWT_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>(
+				JOSEObjectType.JWT, null);
+
+		private static final JOSEObjectTypeVerifier<SecurityContext> NO_TYPE_VERIFIER = (header, context) -> {
+		};
+
 		private final SecretKey secretKey;
 
 		private JWSAlgorithm jwsAlgorithm = JWSAlgorithm.HS256;
 
+		private JOSEObjectTypeVerifier<SecurityContext> typeVerifier = new DefaultJOSEObjectTypeVerifier<>(
+				JOSEObjectType.JWT, null);
+
 		private Consumer<ConfigurableJWTProcessor<SecurityContext>> 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.
+		 *
+		 * <p>
+		 * 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
+		 * </p>
+		 *
+		 * <p>
+		 * This is done for you when you use {@link JwtValidators} to construct a
+		 * validator.
+		 *
+		 * <p>
+		 * That means that this: <code>
+		 *     NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
+		 *     jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer);
+		 * </code>
+		 *
+		 * <p>
+		 * Is equivalent to this: <code>
+		 *     NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer)
+		 *         .validateType(false)
+		 *         .build();
+		 *     jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer);
+		 * </code>
+		 *
+		 * <p>
+		 * The difference is that by setting this to {@code false}, it allows you to
+		 * provide validation by type, like for {@code at+jwt}:
+		 *
+		 * <code>
+		 *     NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer)
+		 *         .validateType(false)
+		 *         .build();
+		 *     jwtDecoder.setJwtValidator(new MyAtJwtValidator());
+		 * </code>
+		 * @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
 		 * <a href="https://tools.ietf.org/html/rfc7515#section-4.1.1" target=
@@ -615,6 +791,7 @@ public final class NimbusJwtDecoder implements JwtDecoder {
 					this.secretKey);
 			DefaultJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
 			jwtProcessor.setJWSKeySelector(jwsKeySelector);
+			jwtProcessor.setJWSTypeVerifier(this.typeVerifier);
 			// Spring Security validates the claim set independent from Nimbus
 			jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> {
 			});

+ 47 - 0
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();
+	}
+
+}

+ 22 - 0
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);