Add Type Validation

Closes gh-16672
This commit is contained in:
Josh Cummings 2025-02-25 13:53:34 -07:00
parent 0c7b05a0e3
commit 81e2fd2fe8
4 changed files with 325 additions and 0 deletions

View File

@ -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"));
}
}

View File

@ -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) -> {
});

View File

@ -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();
}
}

View File

@ -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);