From d204afe73f55813969f5f3ae31424bbd0b884961 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 1/3] 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 00000000000..f138a84c51d --- /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 df0239ebfec..1ff93c837d2 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 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.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 static SecretKeyJwtDecoderBuilder withSecretKey(SecretKey secretKey) { */ 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 @@ private JwkSetUriJwtDecoderBuilder(Function jwkSetUri, }; } + /** + * 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 * jwkSource() { JWTProcessor 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 void close() { */ 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 @@ private PublicKeyJwtDecoderBuilder(RSAPublicKey key) { }; } + /** + * 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 * processor() { + this.jwsAlgorithm + ". Please indicate one of RS256, RS384, or RS512."); JWSKeySelector 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 NimbusJwtDecoder build() { */ 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 @@ private SecretKeyJwtDecoderBuilder(SecretKey secretKey) { }; } + /** + * 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 * processor() { this.secretKey); DefaultJWTProcessor 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 00000000000..b76aaeff6d4 --- /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 d638795df9a..69341707689 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 void withJwkSetUriWhenJwtProcessorCustomizerNullThenThrowsIllegalArgument // @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); From 3ffacded116fb666538d9f396610c56c09667c76 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 27 Feb 2025 11:00:21 -0700 Subject: [PATCH 2/3] Add RFC 9068 Support Closes gh-13185 --- docs/modules/ROOT/pages/migration/oauth2.adoc | 172 +++++++++++++ .../reactive/oauth2/resource-server/jwt.adoc | 40 +++ .../servlet/oauth2/resource-server/jwt.adoc | 40 +++ .../security/oauth2/jwt/JwtTypeValidator.java | 6 +- .../security/oauth2/jwt/JwtValidators.java | 155 ++++++++++++ .../security/oauth2/jwt/NimbusJwtDecoder.java | 18 +- .../oauth2/jwt/NimbusReactiveJwtDecoder.java | 238 +++++++++++++++++- .../oauth2/jwt/JwtValidatorsTests.java | 36 +++ .../oauth2/jwt/NimbusJwtDecoderTests.java | 3 +- .../jwt/NimbusReactiveJwtDecoderTests.java | 56 ++++- 10 files changed, 751 insertions(+), 13 deletions(-) create mode 100644 docs/modules/ROOT/pages/migration/oauth2.adoc diff --git a/docs/modules/ROOT/pages/migration/oauth2.adoc b/docs/modules/ROOT/pages/migration/oauth2.adoc new file mode 100644 index 00000000000..ecca47d04f7 --- /dev/null +++ b/docs/modules/ROOT/pages/migration/oauth2.adoc @@ -0,0 +1,172 @@ += OAuth 2.0 Changes + +== Validate `typ` Header with `JwtTypeValidator` + +`NimbusJwtDecoder` in Spring Security 7 will move `typ` header validation to `JwtTypeValidator` intsead of relying on Nimbus. +This brings it in line with `NimbusJwtDecoder` validating claims instead of relying on Nimbus to validate them. + +If you are changing Nimbus's default type validation in a `jwtProcessorCustomizer` method, then you should move that to `JwtTypeValidator` or an implementation of `OAuth2TokenValidator` of your own. + +To check if you are prepared for this change, add the default `JwtTypeValidator` to your list of validators, as this will be included by default in 7: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +JwtDecoder jwtDecoder() { + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(location) + .validateTypes(false) <1> + // ... your remaining configuration + .build(); + jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + new JwtIssuerValidator(location), JwtTypeValidator.jwt())); <2> + return jwtDecoder; +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): JwtDecoder { + val jwtDecoder = NimbusJwtDecoder.withIssuerLocation(location) + .validateTypes(false) <1> + // ... your remaining configuration + .build() + jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + JwtIssuerValidator(location), JwtTypeValidator.jwt())) <2> + return jwtDecoder +} +---- +====== +<1> - Switch off Nimbus verifying the `typ` (this will be off by default in 7) +<2> - Add the default `typ` validator (this will be included by default in 7) + +Note the default value verifies that the `typ` value either be `JWT` or not present, which is the same as the Nimbus default. +It is also aligned with https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.9[RFC 7515] which states that `typ` is optional. + + +=== I'm Using A `DefaultJOSEObjectTypeVerifier` + +If you have something like the following in your configuration: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +JwtDecoder jwtDecoder() { + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(location) + .jwtProcessorCustomizer((c) -> c + .setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>("JOSE")) + ) + .build(); + return jwtDecoder; +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): JwtDecoder { + val jwtDecoder = NimbusJwtDecoder.withIssuerLocation(location) + .jwtProcessorCustomizer { + it.setJWSTypeVerifier(DefaultJOSEObjectTypeVerifier("JOSE")) + } + .build() + return jwtDecoder +} +---- +====== + +Then change this to: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +JwtDecoder jwtDecoder() { + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(location) + .validateTypes(false) + .build(); + jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + new JwtIssuerValidator(location), new JwtTypeValidator("JOSE"))); + return jwtDecoder; +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): JwtDecoder { + val jwtDecoder = NimbusJwtDecoder.withIssuerLocation(location) + .validateTypes(false) + .build() + jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + JwtIssuerValidator(location), JwtTypeValidator("JOSE"))) + return jwtDecoder +} +---- +====== + +To indicate that the `typ` header is optional, use `#setAllowEmpty(true)` (this is the equivalent of including `null` in the list of allowed types in `DefaultJOSEObjectTypeVerifier`). + +=== I want to opt-out + +If you want to keep doing things the way that you are, then the steps are similar, just in reverse: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +JwtDecoder jwtDecoder() { + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(location) + .validateTypes(true) <1> + .jwtProcessorCustomizer((c) -> c + .setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>("JOSE")) + ) + .build(); + jwtDecoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( + new JwtTimestampValidator(), new JwtIssuerValidator(location))); <2> + return jwtDecoder; +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): JwtDecoder { + val jwtDecoder = NimbusJwtDecoder.withIssuerLocation(location) + .validateTypes(true) <1> + .jwtProcessorCustomizer { + it.setJWSTypeVerifier(DefaultJOSEObjectTypeVerifier("JOSE")) + } + .build() + jwtDecoder.setJwtValidator(DelegatingOAuth2TokenValidator( + JwtTimestampValidator(), JwtIssuerValidator(location))) <2> + return jwtDecoder +} +---- +====== +<1> - leave Nimbus type verification on +<2> - specify the list of validators you need, excluding `JwtTypeValidator` + +For additional guidance, please see the xref:servlet/oauth2/resource-server/jwt.adoc#oauth2resourceserver-jwt-validation[JwtDecoder Validators] section in the reference. diff --git a/docs/modules/ROOT/pages/reactive/oauth2/resource-server/jwt.adoc b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/jwt.adoc index b3d14fdb393..bc9d827f224 100644 --- a/docs/modules/ROOT/pages/reactive/oauth2/resource-server/jwt.adoc +++ b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/jwt.adoc @@ -936,6 +936,46 @@ fun jwtDecoder(): ReactiveJwtDecoder { By default, Resource Server configures a clock skew of 60 seconds. ==== +[[webflux-oauth2resourceserver-validation-rfc9068]] +=== Configuring RFC 9068 Validation + +If you need to require tokens that meet https://datatracker.ietf.org/doc/rfc9068/[RFC 9068], you can configure validation in the following way: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +JwtDecoder jwtDecoder() { + NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withIssuerLocation(issuerUri) + .validateTypes(false).build(); + jwtDecoder.setJwtValidator(JwtValidators.createAtJwtValidator() + .audience("https://audience.example.org") + .clientId("client-identifier") + .issuer("https://issuer.example.org").build()); + return jwtDecoder; +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): JwtDecoder { + val jwtDecoder = NimbusReactiveJwtDecoder.withIssuerLocation(issuerUri) + .validateTypes(false).build() + jwtDecoder.setJwtValidator(JwtValidators.createAtJwtValidator() + .audience("https://audience.example.org") + .clientId("client-identifier") + .issuer("https://issuer.example.org").build()) + return jwtDecoder +} +---- +====== + [[webflux-oauth2resourceserver-validation-custom]] ==== Configuring a Custom Validator diff --git a/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc b/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc index 7551ac87d72..d5e10dbf836 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc @@ -1213,6 +1213,46 @@ fun jwtDecoder(): JwtDecoder { [NOTE] By default, Resource Server configures a clock skew of 60 seconds. +[[oauth2resourceserver-jwt-validation-rfc9068]] +=== Configuring RFC 9068 Validation + +If you need to require tokens that meet https://datatracker.ietf.org/doc/rfc9068/[RFC 9068], you can configure validation in the following way: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +JwtDecoder jwtDecoder() { + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuerUri) + .validateTypes(false).build(); + jwtDecoder.setJwtValidator(JwtValidators.createAtJwtValidator() + .audience("https://audience.example.org") + .clientId("client-identifier") + .issuer("https://issuer.example.org").build()); + return jwtDecoder; +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): JwtDecoder { + val jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuerUri) + .validateTypes(false).build() + jwtDecoder.setJwtValidator(JwtValidators.createAtJwtValidator() + .audience("https://audience.example.org") + .clientId("client-identifier") + .issuer("https://issuer.example.org").build()) + return jwtDecoder +} +---- +====== + [[oauth2resourceserver-jwt-validation-custom]] === Configuring a Custom Validator 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 index f138a84c51d..f5e5b4f9402 100644 --- 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 @@ -36,7 +36,7 @@ */ public final class JwtTypeValidator implements OAuth2TokenValidator { - private Collection validTypes; + private final Collection validTypes; private boolean allowEmpty; @@ -45,6 +45,10 @@ public JwtTypeValidator(Collection validTypes) { this.validTypes = new ArrayList<>(validTypes); } + public JwtTypeValidator(String... validTypes) { + this(List.of(validTypes)); + } + /** * Require that the {@code typ} header be {@code JWT} or absent */ diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidators.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidators.java index 8c2fa20909b..839d02ac1e2 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidators.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtValidators.java @@ -18,10 +18,17 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Predicate; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +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.CollectionUtils; @@ -116,4 +123,152 @@ public static OAuth2TokenValidator createDefaultWithValidators(OAuth2TokenV return createDefaultWithValidators(tokenValidators); } + /** + * Return a {@link AtJwtBuilder} for building a validator that conforms to + * RFC 9068. + * @return the {@link AtJwtBuilder} for configuration + * @since 6.5 + */ + public static AtJwtBuilder createAtJwtValidator() { + return new AtJwtBuilder(); + } + + private static RequireClaimValidator require(String claim) { + return new RequireClaimValidator(claim); + } + + /** + * A class for building a validator that conforms to + * RFC 9068. + * + *

+ * To comply with this spec, this builder needs you to specify at least the + * {@link #audience}, {@link #issuer}, and {@link #clientId}. + * + *

+ * While building, the claims are keyed by claim name to allow for simplified lookup + * and replacement in {@link #validators}. + * + * @author Josh Cummings + * @since 6.5 + */ + public static final class AtJwtBuilder { + + Map> validators = new LinkedHashMap<>(); + + private AtJwtBuilder() { + JwtTimestampValidator timestamps = new JwtTimestampValidator(); + this.validators.put(JoseHeaderNames.TYP, new JwtTypeValidator(List.of("at+jwt", "application/at+jwt"))); + this.validators.put(JwtClaimNames.EXP, require(JwtClaimNames.EXP).and(timestamps)); + this.validators.put(JwtClaimNames.SUB, require(JwtClaimNames.SUB)); + this.validators.put(JwtClaimNames.IAT, require(JwtClaimNames.IAT).and(timestamps)); + this.validators.put(JwtClaimNames.JTI, require(JwtClaimNames.JTI)); + } + + /** + * Validate that each token has this issuer. + * @param issuer the required issuer + * @return the {@link AtJwtBuilder} for further configuration + */ + public AtJwtBuilder issuer(String issuer) { + return validators((v) -> v.put(JwtClaimNames.ISS, new JwtIssuerValidator(issuer))); + } + + /** + * Validate that each token has this audience. + * @param audience the required audience + * @return the {@link AtJwtBuilder} for further configuration + */ + public AtJwtBuilder audience(String audience) { + return validators((v) -> v.put(JwtClaimNames.AUD, + require(JwtClaimNames.AUD).satisfies((jwt) -> jwt.getAudience().contains(audience)))); + } + + /** + * Validate that each token has this client_id. + * @param clientId the client identifier to use + * @return the {@link AtJwtBuilder} for further configuration + */ + public AtJwtBuilder clientId(String clientId) { + return validators((v) -> v.put("client_id", require("client_id").isEqualTo(clientId))); + } + + /** + * Mutate the list of validators by claim name. + * + *

+ * For example, to add a validator for + * azp + * do: + * builder.validators((v) -> v.put("acr", myValidator())); + * + * + *

+ * A validator is required for all required RFC 9068 claims. + * @param validators the mutator for the map of validators + * @return the {@link AtJwtBuilder} for further configuration + */ + public AtJwtBuilder validators(Consumer>> validators) { + validators.accept(this.validators); + return this; + } + + /** + * Build the validator + * @return the RFC 9068 validator + */ + public OAuth2TokenValidator build() { + List.of(JoseHeaderNames.TYP, JwtClaimNames.EXP, JwtClaimNames.SUB, JwtClaimNames.IAT, JwtClaimNames.JTI, + JwtClaimNames.ISS, JwtClaimNames.AUD, "client_id") + .forEach((name) -> Assert.isTrue(this.validators.containsKey(name), name + " must be validated")); + return new DelegatingOAuth2TokenValidator<>(this.validators.values()); + } + + } + + private static final class RequireClaimValidator implements OAuth2TokenValidator { + + private final String claimName; + + RequireClaimValidator(String claimName) { + this.claimName = claimName; + } + + @Override + public OAuth2TokenValidatorResult validate(Jwt token) { + if (token.getClaim(this.claimName) == null) { + return OAuth2TokenValidatorResult + .failure(new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, this.claimName + " must have a value", + "https://datatracker.ietf.org/doc/html/rfc9068#name-data-structure")); + } + return OAuth2TokenValidatorResult.success(); + } + + OAuth2TokenValidator isEqualTo(String value) { + return and(satisfies((jwt) -> value.equals(jwt.getClaim(this.claimName)))); + } + + OAuth2TokenValidator satisfies(Predicate predicate) { + return and((jwt) -> { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, this.claimName + " is not valid", + "https://datatracker.ietf.org/doc/html/rfc9068#name-data-structure"); + if (predicate.test(jwt)) { + return OAuth2TokenValidatorResult.success(); + } + return OAuth2TokenValidatorResult.failure(error); + }); + } + + OAuth2TokenValidator and(OAuth2TokenValidator that) { + return (jwt) -> { + OAuth2TokenValidatorResult result = validate(jwt); + return (result.hasErrors()) ? result : that.validate(jwt); + }; + } + + } + } 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 1ff93c837d2..5fb6e54b6b9 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 @@ -279,8 +279,7 @@ public static final class JwkSetUriJwtDecoderBuilder { private Function, Set> defaultAlgorithms = (source) -> Set .of(JWSAlgorithm.RS256); - private JOSEObjectTypeVerifier typeVerifier = new DefaultJOSEObjectTypeVerifier<>( - JOSEObjectType.JWT, null); + private JOSEObjectTypeVerifier typeVerifier = JWT_TYPE_VERIFIER; private Set signatureAlgorithms = new HashSet<>(); @@ -332,7 +331,8 @@ private JwkSetUriJwtDecoderBuilder(Function jwkSetUri, * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer) * .validateType(false) * .build(); - * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + * new JwtIssuerValidator(issuer), JwtTypeValidator.jwt()); * * *

@@ -550,8 +550,7 @@ public static final class PublicKeyJwtDecoderBuilder { private JWSAlgorithm jwsAlgorithm; - private JOSEObjectTypeVerifier typeVerifier = new DefaultJOSEObjectTypeVerifier<>( - JOSEObjectType.JWT, null); + private JOSEObjectTypeVerifier typeVerifier = JWT_TYPE_VERIFIER; private RSAPublicKey key; @@ -590,7 +589,8 @@ private PublicKeyJwtDecoderBuilder(RSAPublicKey key) { * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer) * .validateType(false) * .build(); - * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + * new JwtIssuerValidator(issuer), JwtTypeValidator.jwt()); * * *

@@ -686,8 +686,7 @@ public static final class SecretKeyJwtDecoderBuilder { private JWSAlgorithm jwsAlgorithm = JWSAlgorithm.HS256; - private JOSEObjectTypeVerifier typeVerifier = new DefaultJOSEObjectTypeVerifier<>( - JOSEObjectType.JWT, null); + private JOSEObjectTypeVerifier typeVerifier = JWT_TYPE_VERIFIER; private Consumer> jwtProcessorCustomizer; @@ -723,7 +722,8 @@ private SecretKeyJwtDecoderBuilder(SecretKey secretKey) { * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer) * .validateType(false) * .build(); - * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + * new JwtIssuerValidator(issuer), JwtTypeValidator.jwt()); * * *

diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoder.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoder.java index bad9ecf2246..32f97c1355b 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoder.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoder.java @@ -32,6 +32,7 @@ import com.nimbusds.jose.Header; import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.JWSHeader; import com.nimbusds.jose.jwk.JWK; @@ -39,6 +40,8 @@ import com.nimbusds.jose.jwk.JWKSelector; import com.nimbusds.jose.jwk.source.JWKSecurityContextJWKSet; import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; +import com.nimbusds.jose.proc.JOSEObjectTypeVerifier; import com.nimbusds.jose.proc.JWKSecurityContext; import com.nimbusds.jose.proc.JWSKeySelector; import com.nimbusds.jose.proc.JWSVerificationKeySelector; @@ -308,6 +311,12 @@ private static JWTClaimsSet createClaimsSet(JWTProce */ public static final class JwkSetUriReactiveJwtDecoderBuilder { + private static final JOSEObjectTypeVerifier JWT_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>( + JOSEObjectType.JWT, null); + + private static final JOSEObjectTypeVerifier NO_TYPE_VERIFIER = (header, context) -> { + }; + private static final Duration FOREVER = Duration.ofMillis(Long.MAX_VALUE); private Function> jwkSetUri; @@ -315,6 +324,8 @@ public static final class JwkSetUriReactiveJwtDecoderBuilder { private Function>> defaultAlgorithms = (source) -> Mono .just(Set.of(JWSAlgorithm.RS256)); + private JOSEObjectTypeVerifier typeVerifier = JWT_TYPE_VERIFIER; + private Set signatureAlgorithms = new HashSet<>(); private WebClient webClient = WebClient.create(); @@ -349,6 +360,55 @@ public JwkSetUriReactiveJwtDecoderBuilder jwsAlgorithm(SignatureAlgorithm signat return this; } + /** + * 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 NimbusReactiveJwtDecoder} 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: + * NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withIssuerLocation(issuer).build(); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer); + * + * + *

+ * Is equivalent to this: + * NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withIssuerLocation(issuer) + * .validateType(false) + * .build(); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + * new JwtIssuerValidator(issuer), JwtTypeValidator.jwt()); + * + * + *

+ * The difference is that by setting this to {@code false}, it allows you to + * provide validation by type, like for {@code at+jwt}: + * + * + * NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withIssuerLocation(issuer) + * .validateType(false) + * .build(); + * jwtDecoder.setJwtValidator(new MyAtJwtValidator()); + * + * @param shouldValidateTypHeader whether Nimbus should validate the typ header or + * not + * @return a {@link JwkSetUriReactiveJwtDecoderBuilder} for further configurations + * @since 6.5 + */ + public JwkSetUriReactiveJwtDecoderBuilder validateType(boolean shouldValidateTypHeader) { + this.typeVerifier = shouldValidateTypHeader ? JWT_TYPE_VERIFIER : NO_TYPE_VERIFIER; + return this; + } + /** * Configure the list of * > processor() { Mono, Function>> jwtProcessorMono = jwsKeySelector .flatMap((selector) -> { jwtProcessor.setJWSKeySelector(selector); + jwtProcessor.setJWSTypeVerifier(this.typeVerifier); return this.jwtProcessorCustomizer.apply(source, jwtProcessor); }) .map((processor) -> Tuples.of(processor, getExpectedJwsAlgorithms(processor.getJWSKeySelector()))) .cache((processor) -> FOREVER, (ex) -> Duration.ZERO, () -> Duration.ZERO); return (jwt) -> { return jwtProcessorMono.flatMap((tuple) -> { - JWTProcessor processor = tuple.getT1(); + ConfigurableJWTProcessor processor = tuple.getT1(); Function expectedJwsAlgorithms = tuple.getT2(); JWKSelector selector = createSelector(expectedJwsAlgorithms, jwt.getHeader()); return source.get(selector) @@ -476,10 +537,18 @@ private JWKSelector createSelector(Function expectedJwsAl */ public static final class PublicKeyReactiveJwtDecoderBuilder { + private static final JOSEObjectTypeVerifier JWT_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>( + JOSEObjectType.JWT, null); + + private static final JOSEObjectTypeVerifier NO_TYPE_VERIFIER = (header, context) -> { + }; + private final RSAPublicKey key; private JWSAlgorithm jwsAlgorithm; + private JOSEObjectTypeVerifier typeVerifier = JWT_TYPE_VERIFIER; + private Consumer> jwtProcessorCustomizer; private PublicKeyReactiveJwtDecoderBuilder(RSAPublicKey key) { @@ -505,6 +574,56 @@ public PublicKeyReactiveJwtDecoderBuilder signatureAlgorithm(SignatureAlgorithm return this; } + /** + * 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 NimbusReactiveJwtDecoder} 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: + * NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withIssuerLocation(issuer).build(); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer); + * + * + *

+ * Is equivalent to this: + * NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withPublicKey(key) + * .validateType(false) + * .build(); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + * new JwtIssuerValidator(issuer), JwtTypeValidator.jwt()); + * new JwtIssuerValidator(issuer), JwtTypeValidator.jwt()); + * + * + *

+ * The difference is that by setting this to {@code false}, it allows you to + * provide validation by type, like for {@code at+jwt}: + * + * + * NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withPublicKey(key) + * .validateType(false) + * .build(); + * jwtDecoder.setJwtValidator(new MyAtJwtValidator()); + * + * @param shouldValidateTypHeader whether Nimbus should validate the typ header or + * not + * @return a {@link PublicKeyReactiveJwtDecoderBuilder} for further configurations + * @since 6.5 + */ + public PublicKeyReactiveJwtDecoderBuilder validateType(boolean shouldValidateTypHeader) { + this.typeVerifier = shouldValidateTypHeader ? JWT_TYPE_VERIFIER : NO_TYPE_VERIFIER; + return this; + } + /** * Use the given {@link Consumer} to customize the {@link JWTProcessor * ConfigurableJWTProcessor} before passing it to the build @@ -535,6 +654,7 @@ Converter> processor() { JWSKeySelector jwsKeySelector = new SingleKeyJWSKeySelector<>(this.jwsAlgorithm, this.key); DefaultJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); jwtProcessor.setJWSKeySelector(jwsKeySelector); + jwtProcessor.setJWSTypeVerifier(this.typeVerifier); // Spring Security validates the claim set independent from Nimbus jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> { }); @@ -552,10 +672,18 @@ Converter> processor() { */ public static final class SecretKeyReactiveJwtDecoderBuilder { + 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 = JWT_TYPE_VERIFIER; + private Consumer> jwtProcessorCustomizer; private SecretKeyReactiveJwtDecoderBuilder(SecretKey secretKey) { @@ -582,6 +710,55 @@ public SecretKeyReactiveJwtDecoderBuilder macAlgorithm(MacAlgorithm macAlgorithm return this; } + /** + * 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 NimbusReactiveJwtDecoder} 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: + * NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withIssuerLocation(issuer).build(); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer); + * + * + *

+ * Is equivalent to this: + * NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withSecretKey(key) + * .validateType(false) + * .build(); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + * new JwtIssuerValidator(issuer), JwtTypeValidator.jwt()); + * + * + *

+ * The difference is that by setting this to {@code false}, it allows you to + * provide validation by type, like for {@code at+jwt}: + * + * + * NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withSecretKey(key) + * .validateType(false) + * .build(); + * jwtDecoder.setJwtValidator(new MyAtJwtValidator()); + * + * @param shouldValidateTypHeader whether Nimbus should validate the typ header or + * not + * @return a {@link PublicKeyReactiveJwtDecoderBuilder} for further configurations + * @since 6.5 + */ + public SecretKeyReactiveJwtDecoderBuilder validateType(boolean shouldValidateTypHeader) { + this.typeVerifier = shouldValidateTypHeader ? JWT_TYPE_VERIFIER : NO_TYPE_VERIFIER; + return this; + } + /** * Use the given {@link Consumer} to customize the {@link JWTProcessor * ConfigurableJWTProcessor} before passing it to the build @@ -610,6 +787,7 @@ Converter> processor() { this.secretKey); DefaultJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); jwtProcessor.setJWSKeySelector(jwsKeySelector); + jwtProcessor.setJWSTypeVerifier(this.typeVerifier); // Spring Security validates the claim set independent from Nimbus jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> { }); @@ -626,10 +804,18 @@ Converter> processor() { */ public static final class JwkSourceReactiveJwtDecoderBuilder { + private static final JOSEObjectTypeVerifier JWT_TYPE_VERIFIER = new DefaultJOSEObjectTypeVerifier<>( + JOSEObjectType.JWT, null); + + private static final JOSEObjectTypeVerifier NO_TYPE_VERIFIER = (header, context) -> { + }; + private final Function> jwkSource; private JWSAlgorithm jwsAlgorithm = JWSAlgorithm.RS256; + private JOSEObjectTypeVerifier typeVerifier = JWT_TYPE_VERIFIER; + private Consumer> jwtProcessorCustomizer; private JwkSourceReactiveJwtDecoderBuilder(Function> jwkSource) { @@ -652,6 +838,55 @@ public JwkSourceReactiveJwtDecoderBuilder jwsAlgorithm(JwsAlgorithm jwsAlgorithm return this; } + /** + * 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 NimbusReactiveJwtDecoder} 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: + * NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withJwkSource(issuer).build(); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer); + * + * + *

+ * Is equivalent to this: + * NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withJwkSource(key) + * .validateType(false) + * .build(); + * jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithValidators( + * new JwtIssuerValidator(issuer), JwtTypeValidator.jwt()); + * + * + *

+ * The difference is that by setting this to {@code false}, it allows you to + * provide validation by type, like for {@code at+jwt}: + * + * + * NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withJwkSource(key) + * .validateType(false) + * .build(); + * jwtDecoder.setJwtValidator(new MyAtJwtValidator()); + * + * @param shouldValidateTypHeader whether Nimbus should validate the typ header or + * not + * @return a {@link JwkSourceReactiveJwtDecoderBuilder} for further configurations + * @since 6.5 + */ + public JwkSourceReactiveJwtDecoderBuilder validateType(boolean shouldValidateTypHeader) { + this.typeVerifier = shouldValidateTypHeader ? JWT_TYPE_VERIFIER : NO_TYPE_VERIFIER; + return this; + } + /** * Use the given {@link Consumer} to customize the {@link JWTProcessor * ConfigurableJWTProcessor} before passing it to the build @@ -681,6 +916,7 @@ Converter> processor() { jwkSource); DefaultJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); jwtProcessor.setJWSKeySelector(jwsKeySelector); + jwtProcessor.setJWSTypeVerifier(this.typeVerifier); jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> { }); this.jwtProcessorCustomizer.accept(jwtProcessor); diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtValidatorsTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtValidatorsTests.java index 5c4cf6897c1..33174e91812 100644 --- a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtValidatorsTests.java +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/JwtValidatorsTests.java @@ -18,12 +18,14 @@ import java.util.Collection; import java.util.Collections; +import java.util.List; import java.util.Objects; import org.junit.jupiter.api.Test; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.CollectionUtils; @@ -68,6 +70,40 @@ public void createWhenEmptyValidatorsThenThrowsException() { assertThatException().isThrownBy(() -> JwtValidators.createDefaultWithValidators(Collections.emptyList())); } + @Test + public void createAtJwtWhenIssuerClientIdAudienceThenBuilds() { + Jwt.Builder builder = TestJwts.jwt(); + OAuth2TokenValidator validator = JwtValidators.createAtJwtValidator() + .audience("audience") + .clientId("clientId") + .issuer("issuer") + .build(); + + OAuth2TokenValidatorResult result = validator.validate(builder.build()); + assertThat(result.getErrors().toString()).contains("at+jwt") + .contains("aud") + .contains("client_id") + .contains("iss"); + + result = validator.validate(builder.header(JoseHeaderNames.TYP, "JWT").build()); + assertThat(result.getErrors().toString()).contains("at+jwt"); + + result = validator.validate(builder.header(JoseHeaderNames.TYP, "at+jwt").build()); + assertThat(result.getErrors().toString()).doesNotContain("at+jwt"); + + result = validator.validate(builder.header(JoseHeaderNames.TYP, "application/at+jwt").build()); + assertThat(result.getErrors().toString()).doesNotContain("at+jwt"); + + result = validator.validate(builder.audience(List.of("audience")).build()); + assertThat(result.getErrors().toString()).doesNotContain("aud"); + + result = validator.validate(builder.claim("client_id", "clientId").build()); + assertThat(result.getErrors().toString()).doesNotContain("client_id"); + + result = validator.validate(builder.issuer("issuer").build()); + assertThat(result.getErrors().toString()).doesNotContain("iss"); + } + @SuppressWarnings("unchecked") private boolean containsByType(OAuth2TokenValidator validator, Class> type) { DelegatingOAuth2TokenValidator delegatingOAuth2TokenValidator = (DelegatingOAuth2TokenValidator) validator; 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 69341707689..9b7805a0d89 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 @@ -849,7 +849,8 @@ public void decodeWhenSecretKeyValidateTypeFalseThenSkipsNimbusTypeValidation() NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withSecretKey(TestKeys.DEFAULT_SECRET_KEY) .validateType(false) .build(); - SignedJWT jwt = signedJwt(TestKeys.DEFAULT_SECRET_KEY, MacAlgorithm.HS256, + SignedJWT jwt = signedJwt(TestKeys.DEFAULT_SECRET_KEY, + new JWSHeader.Builder(JWSAlgorithm.HS256).type(JOSEObjectType.JOSE).build(), new JWTClaimsSet.Builder().subject("subject").build()); jwtDecoder.decode(jwt.serialize()); } diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoderTests.java index f39ef50c032..6ec57ab2cc3 100644 --- a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoderTests.java +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoderTests.java @@ -19,6 +19,8 @@ import java.net.UnknownHostException; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.EncodedKeySpec; import java.security.spec.InvalidKeySpecException; @@ -38,6 +40,8 @@ import com.nimbusds.jose.JWSHeader; import com.nimbusds.jose.JWSSigner; import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.JWKSecurityContextJWKSet; @@ -658,10 +662,60 @@ public void jwsKeySelectorWhenMultipleAlgorithmThenReturnsCompositeSelector() { assertThat(jwsAlgorithmMapKeySelector.isAllowed(JWSAlgorithm.RS512)).isTrue(); } + @Test + public void decodeWhenPublicKeyValidateTypeFalseThenSkipsNimbusTypeValidation() throws Exception { + NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.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()).block(); + } + + @Test + public void decodeWhenSecretKeyValidateTypeFalseThenSkipsNimbusTypeValidation() throws Exception { + NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withSecretKey(TestKeys.DEFAULT_SECRET_KEY) + .validateType(false) + .build(); + SignedJWT jwt = signedJwt(TestKeys.DEFAULT_SECRET_KEY, + new JWSHeader.Builder(JWSAlgorithm.HS256).type(JOSEObjectType.JOSE).build(), + new JWTClaimsSet.Builder().subject("subject").build()); + jwtDecoder.decode(jwt.serialize()).block(); + } + + @Test + public void decodeWhenJwkSourceValidateTypeFalseThenSkipsNimbusTypeValidation() throws Exception { + JWK jwk = new RSAKey.Builder(TestKeys.DEFAULT_PUBLIC_KEY).privateKey(TestKeys.DEFAULT_PRIVATE_KEY) + .algorithm(JWSAlgorithm.RS256) + .build(); + NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withJwkSource((jwt) -> Flux.just(jwk)) + .validateType(false) + .build(); + SignedJWT jwt = signedJwt(TestKeys.DEFAULT_PRIVATE_KEY, + new JWSHeader.Builder(JWSAlgorithm.RS256).type(JOSEObjectType.JOSE).build(), + new JWTClaimsSet.Builder().subject("subject").build()); + jwtDecoder.decode(jwt.serialize()).block(); + } + private SignedJWT signedJwt(SecretKey secretKey, MacAlgorithm jwsAlgorithm, JWTClaimsSet claimsSet) throws Exception { - SignedJWT signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.parse(jwsAlgorithm.getName())), claimsSet); + return signedJwt(secretKey, new JWSHeader(JWSAlgorithm.parse(jwsAlgorithm.getName())), claimsSet); + } + + private SignedJWT signedJwt(SecretKey secretKey, JWSHeader header, JWTClaimsSet claimsSet) throws Exception { JWSSigner signer = new MACSigner(secretKey); + return signedJwt(signer, header, claimsSet); + } + + private SignedJWT signedJwt(PrivateKey privateKey, JWSHeader header, JWTClaimsSet claimsSet) throws Exception { + JWSSigner signer = new RSASSASigner(privateKey); + return signedJwt(signer, header, claimsSet); + } + + private SignedJWT signedJwt(JWSSigner signer, JWSHeader header, JWTClaimsSet claimsSet) throws Exception { + SignedJWT signedJWT = new SignedJWT(header, claimsSet); signedJWT.sign(signer); return signedJWT; } From a3723650fb502fd4558daa2138c6ca3564940691 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 27 Feb 2025 11:04:16 -0700 Subject: [PATCH 3/3] Polish NimbusJwtDecoder --- .../security/oauth2/jwt/NimbusJwtDecoder.java | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) 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 5fb6e54b6b9..eb5efbabecb 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 @@ -130,7 +130,7 @@ public void setClaimSetConverter(Converter, Map NO_TYPE_VERIFIER = (header, context) -> { }; - private Function jwkSetUri; + private final Function jwkSetUri; private Function, Set> defaultAlgorithms = (source) -> Set .of(JWSAlgorithm.RS256); private JOSEObjectTypeVerifier typeVerifier = JWT_TYPE_VERIFIER; - private Set signatureAlgorithms = new HashSet<>(); + private final Set signatureAlgorithms = new HashSet<>(); private RestOperations restOperations = new RestTemplate(); @@ -337,9 +337,7 @@ private JwkSetUriJwtDecoderBuilder(Function jwkSetUri, * *

* The difference is that by setting this to {@code false}, it allows you to - * provide validation by type, like for {@code at+jwt}: - * - * + * provide validation by type, like for {@code at+jwt}: * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer) * .validateType(false) * .build(); @@ -388,8 +386,8 @@ public JwkSetUriJwtDecoderBuilder jwsAlgorithms(Consumer * JWK Set uri as well * as the Issuer. - * @param restOperations - * @return + * @param restOperations the {@link RestOperations} instance to use + * @return a {@link JwkSetUriJwtDecoderBuilder} for further configurations */ public JwkSetUriJwtDecoderBuilder restOperations(RestOperations restOperations) { Assert.notNull(restOperations, "restOperations cannot be null"); @@ -552,7 +550,7 @@ public static final class PublicKeyJwtDecoderBuilder { private JOSEObjectTypeVerifier typeVerifier = JWT_TYPE_VERIFIER; - private RSAPublicKey key; + private final RSAPublicKey key; private Consumer> jwtProcessorCustomizer; @@ -595,9 +593,7 @@ private PublicKeyJwtDecoderBuilder(RSAPublicKey key) { * *

* The difference is that by setting this to {@code false}, it allows you to - * provide validation by type, like for {@code at+jwt}: - * - * + * provide validation by type, like for {@code at+jwt}: * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer) * .validateType(false) * .build(); @@ -616,9 +612,7 @@ public PublicKeyJwtDecoderBuilder validateType(boolean shouldValidateTypHeader) /** * Use the given signing * algorithm. - * - * The value should be one of + * "_blank">algorithm. The value should be one of * RS256, RS384, or RS512. * @param signatureAlgorithm the algorithm to use @@ -728,9 +722,7 @@ private SecretKeyJwtDecoderBuilder(SecretKey secretKey) { * *

* The difference is that by setting this to {@code false}, it allows you to - * provide validation by type, like for {@code at+jwt}: - * - * + * provide validation by type, like for {@code at+jwt}: * NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer) * .validateType(false) * .build(); @@ -749,9 +741,7 @@ public SecretKeyJwtDecoderBuilder validateType(boolean shouldValidateTypHeader) /** * Use the given * algorithm when generating the MAC. - * - * The value should be one of + * "_blank">algorithm when generating the MAC. The value should be one of * HS256, HS384 or HS512. * @param macAlgorithm the MAC algorithm to use