diff --git a/docs/modules/ROOT/pages/migration/oauth2.adoc b/docs/modules/ROOT/pages/migration/oauth2.adoc new file mode 100644 index 0000000000..ecca47d04f --- /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 b3d14fdb39..bc9d827f22 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 7551ac87d7..d5e10dbf83 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 f138a84c51..f5e5b4f940 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 @@ import org.springframework.util.StringUtils; */ public final class JwtTypeValidator implements OAuth2TokenValidator { - private Collection validTypes; + private final Collection validTypes; private boolean allowEmpty; @@ -45,6 +45,10 @@ public final class JwtTypeValidator implements OAuth2TokenValidator { 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 8c2fa20909..839d02ac1e 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 @@ package org.springframework.security.oauth2.jwt; 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 final class JwtValidators { 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 1ff93c837d..5fb6e54b6b 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 final class NimbusJwtDecoder implements JwtDecoder { 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 @@ public final class NimbusJwtDecoder implements JwtDecoder { * 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 final class NimbusJwtDecoder implements JwtDecoder { private JWSAlgorithm jwsAlgorithm; - private JOSEObjectTypeVerifier typeVerifier = new DefaultJOSEObjectTypeVerifier<>( - JOSEObjectType.JWT, null); + private JOSEObjectTypeVerifier typeVerifier = JWT_TYPE_VERIFIER; private RSAPublicKey key; @@ -590,7 +589,8 @@ public final class NimbusJwtDecoder implements JwtDecoder { * 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 final class NimbusJwtDecoder implements JwtDecoder { 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 @@ public final class NimbusJwtDecoder implements JwtDecoder { * 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 bad9ecf224..32f97c1355 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 javax.crypto.SecretKey; 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.JWKMatcher; 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 @@ public final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { */ 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 final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { 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 final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { 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 * , 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 @@ public final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { */ 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 final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { 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 @@ public final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { 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 @@ public final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { */ 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 final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { 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 @@ public final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { 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 @@ public final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { */ 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 final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { 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 @@ public final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { 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 5c4cf6897c..33174e9181 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 @@ package org.springframework.security.oauth2.jwt; 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 class JwtValidatorsTests { 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 6934170768..9b7805a0d8 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 class NimbusJwtDecoderTests { 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 f39ef50c03..6ec57ab2cc 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 @@ package org.springframework.security.oauth2.jwt; 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.JWSAlgorithm; 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 class NimbusReactiveJwtDecoderTests { 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; }