From c3e51d32b832f14766671ee64dfbd5e4424c3fee Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Thu, 27 Nov 2025 14:07:05 +0000 Subject: [PATCH] Allow a JwtTypeValidator bean to override Security's default A change in Spring Security [1] means that type validation is now performed by default by Spring Security. A breaking side-effect of this is that setting validateTypes to false no longer has an effect and the default JwtTypeValidator is still present. Its presence, wrapped in a DelegatingOAuth2TokenValidator, prevents a user's JwtTypeValidator bean from being used for type validation. This commit updates Boot's auto-configuration to change how the type validators are created. We avoid wrapping in a DelegatingOAuth2TokenValidator so that the user's custom JwtTypeValidator can be detected and used in place of the default. This requires us to create the JwtIssuerValidator rather than using the createDefaultWithIssuer method as it does not allow additional validators to be provided. Fixes gh-48301 [1] https://github.com/spring-projects/spring-security/commit/6d3b54df21ec0cffc30c8b3e0784220bd117a87d --- ...eOAuth2ResourceServerJwkConfiguration.java | 28 +++++--- .../OAuth2ResourceServerJwtConfiguration.java | 28 +++++--- ...2ResourceServerAutoConfigurationTests.java | 68 ++++++++++++++++++- ...2ResourceServerAutoConfigurationTests.java | 67 +++++++++++++++++- 4 files changed, 169 insertions(+), 22 deletions(-) diff --git a/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java b/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java index 2614260fb4c..717c9d7e4a8 100644 --- a/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java +++ b/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java @@ -41,12 +41,12 @@ import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity.OAuth2ResourceServerSpec; -import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.jwt.JwtIssuerValidator; import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder.JwkSetUriReactiveJwtDecoderBuilder; @@ -99,9 +99,13 @@ class ReactiveOAuth2ResourceServerJwkConfiguration { customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); NimbusReactiveJwtDecoder nimbusReactiveJwtDecoder = builder.build(); String issuerUri = this.properties.getIssuerUri(); - OAuth2TokenValidator defaultValidator = (issuerUri != null) - ? JwtValidators.createDefaultWithIssuer(issuerUri) : JwtValidators.createDefault(); - nimbusReactiveJwtDecoder.setJwtValidator(getValidators(defaultValidator)); + List> validators = new ArrayList<>(); + if (issuerUri != null) { + validators.add(new JwtIssuerValidator(issuerUri)); + } + validators.addAll(getValidators()); + nimbusReactiveJwtDecoder.setJwtValidator(validators.isEmpty() ? JwtValidators.createDefault() + : JwtValidators.createDefaultWithValidators(validators)); return nimbusReactiveJwtDecoder; } @@ -111,18 +115,17 @@ class ReactiveOAuth2ResourceServerJwkConfiguration { } } - private OAuth2TokenValidator getValidators(OAuth2TokenValidator defaultValidator) { + private List> getValidators() { List audiences = this.properties.getAudiences(); if (CollectionUtils.isEmpty(audiences) && this.additionalValidators.isEmpty()) { - return defaultValidator; + return Collections.emptyList(); } List> validators = new ArrayList<>(); - validators.add(defaultValidator); if (!CollectionUtils.isEmpty(audiences)) { validators.add(audValidator(audiences)); } validators.addAll(this.additionalValidators); - return new DelegatingOAuth2TokenValidator<>(validators); + return validators; } private JwtClaimValidator> audValidator(List audiences) { @@ -141,7 +144,9 @@ class ReactiveOAuth2ResourceServerJwkConfiguration { NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withPublicKey(publicKey) .signatureAlgorithm(SignatureAlgorithm.from(exactlyOneAlgorithm())) .build(); - jwtDecoder.setJwtValidator(getValidators(JwtValidators.createDefault())); + List> validators = getValidators(); + jwtDecoder.setJwtValidator(validators.isEmpty() ? JwtValidators.createDefault() + : JwtValidators.createDefaultWithValidators(validators)); return jwtDecoder; } @@ -171,7 +176,10 @@ class ReactiveOAuth2ResourceServerJwkConfiguration { JwkSetUriReactiveJwtDecoderBuilder builder = NimbusReactiveJwtDecoder.withIssuerLocation(issuerUri); customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); NimbusReactiveJwtDecoder jwtDecoder = builder.build(); - jwtDecoder.setJwtValidator(getValidators(JwtValidators.createDefaultWithIssuer(issuerUri))); + List> validators = new ArrayList<>(); + validators.add(new JwtIssuerValidator(issuerUri)); + validators.addAll(getValidators()); + jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithValidators(validators)); return jwtDecoder; }); } diff --git a/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/servlet/OAuth2ResourceServerJwtConfiguration.java b/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/servlet/OAuth2ResourceServerJwtConfiguration.java index 735409d206a..573c4a9a823 100644 --- a/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/servlet/OAuth2ResourceServerJwtConfiguration.java +++ b/module/spring-boot-security-oauth2-resource-server/src/main/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/servlet/OAuth2ResourceServerJwtConfiguration.java @@ -41,13 +41,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.security.oauth2.jwt.JwtClaimValidator; import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtIssuerValidator; import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder; @@ -97,9 +97,13 @@ class OAuth2ResourceServerJwtConfiguration { customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); NimbusJwtDecoder nimbusJwtDecoder = builder.build(); String issuerUri = this.properties.getIssuerUri(); - OAuth2TokenValidator defaultValidator = (issuerUri != null) - ? JwtValidators.createDefaultWithIssuer(issuerUri) : JwtValidators.createDefault(); - nimbusJwtDecoder.setJwtValidator(getValidators(defaultValidator)); + List> validators = new ArrayList<>(); + if (issuerUri != null) { + validators.add(new JwtIssuerValidator(issuerUri)); + } + validators.addAll(getValidators()); + nimbusJwtDecoder.setJwtValidator(validators.isEmpty() ? JwtValidators.createDefault() + : JwtValidators.createDefaultWithValidators(validators)); return nimbusJwtDecoder; } @@ -109,18 +113,17 @@ class OAuth2ResourceServerJwtConfiguration { } } - private OAuth2TokenValidator getValidators(OAuth2TokenValidator defaultValidator) { + private List> getValidators() { List audiences = this.properties.getAudiences(); if (CollectionUtils.isEmpty(audiences) && this.additionalValidators.isEmpty()) { - return defaultValidator; + return Collections.emptyList(); } List> validators = new ArrayList<>(); - validators.add(defaultValidator); if (!CollectionUtils.isEmpty(audiences)) { validators.add(audValidator(audiences)); } validators.addAll(this.additionalValidators); - return new DelegatingOAuth2TokenValidator<>(validators); + return validators; } private JwtClaimValidator> audValidator(List audiences) { @@ -139,7 +142,9 @@ class OAuth2ResourceServerJwtConfiguration { NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(publicKey) .signatureAlgorithm(SignatureAlgorithm.from(exactlyOneAlgorithm())) .build(); - jwtDecoder.setJwtValidator(getValidators(JwtValidators.createDefault())); + List> validators = getValidators(); + jwtDecoder.setJwtValidator(validators.isEmpty() ? JwtValidators.createDefault() + : JwtValidators.createDefaultWithValidators(validators)); return jwtDecoder; } @@ -168,7 +173,10 @@ class OAuth2ResourceServerJwtConfiguration { JwkSetUriJwtDecoderBuilder builder = NimbusJwtDecoder.withIssuerLocation(issuerUri); customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); NimbusJwtDecoder jwtDecoder = builder.build(); - jwtDecoder.setJwtValidator(getValidators(JwtValidators.createDefaultWithIssuer(issuerUri))); + List> validators = new ArrayList<>(); + validators.add(new JwtIssuerValidator(issuerUri)); + validators.addAll(getValidators()); + jwtDecoder.setJwtValidator(JwtValidators.createDefaultWithValidators(validators)); return jwtDecoder; }); } diff --git a/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java b/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java index 6fd94d6b666..824656ae063 100644 --- a/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java +++ b/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java @@ -73,9 +73,11 @@ import org.springframework.security.core.userdetails.MapReactiveUserDetailsServi import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JoseHeaderNames; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtClaimValidator; import org.springframework.security.oauth2.jwt.JwtIssuerValidator; +import org.springframework.security.oauth2.jwt.JwtTypeValidator; import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.SupplierReactiveJwtDecoder; @@ -727,6 +729,60 @@ class ReactiveOAuth2ResourceServerAutoConfigurationTests { .doesNotHaveBean(ReactiveManagementWebSecurityAutoConfiguration.class)); } + @Test + @SuppressWarnings("unchecked") + void customTypeValidatorCanReplaceDefaultWhenUsingIssuerUri() throws Exception { + this.server = new MockWebServer(); + this.server.start(); + String path = "test"; + String issuer = this.server.url(path).toString(); + String cleanIssuerPath = cleanIssuerPath(issuer); + setupMockResponse(cleanIssuerPath); + String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path; + this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri) + .withUserConfiguration(CustomJwtTypeValidatorConfig.class) + .run((context) -> { + SupplierReactiveJwtDecoder supplierJwtDecoderBean = context.getBean(SupplierReactiveJwtDecoder.class); + Mono jwtDecoderSupplier = (Mono) ReflectionTestUtils + .getField(supplierJwtDecoderBean, "jwtDecoderMono"); + assertThat(jwtDecoderSupplier).isNotNull(); + ReactiveJwtDecoder jwtDecoder = jwtDecoderSupplier.block(); + assertThat(jwtDecoder).isNotNull(); + assertThat(context).hasBean("customJwtTypeValidator"); + OAuth2TokenValidator customValidator = (OAuth2TokenValidator) context + .getBean("customJwtTypeValidator"); + validate(jwt().claim("iss", URI.create(issuerUri).toURL()).header(JoseHeaderNames.TYP, "custom-type"), + jwtDecoder, + (validators) -> assertThat(validators).contains(customValidator) + .satisfiesOnlyOnce( + (validator) -> assertThat(validator).isInstanceOf(JwtTypeValidator.class))); + }); + } + + @Test + @SuppressWarnings("unchecked") + void customTypeValidatorCanReplaceDefaultWhenUsingJwkSetUri() throws Exception { + this.server = new MockWebServer(); + this.server.start(); + String path = "test"; + String issuer = this.server.url(path).toString(); + String cleanIssuerPath = cleanIssuerPath(issuer); + setupMockResponse(cleanIssuerPath); + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://jwk-set-uri.com") + .withUserConfiguration(CustomJwtTypeValidatorConfig.class) + .run((context) -> { + ReactiveJwtDecoder jwtDecoder = context.getBean(ReactiveJwtDecoder.class); + assertThat(context).hasBean("customJwtTypeValidator"); + OAuth2TokenValidator customValidator = (OAuth2TokenValidator) context + .getBean("customJwtTypeValidator"); + validate(jwt().header(JoseHeaderNames.TYP, "custom-type"), jwtDecoder, + (validators) -> assertThat(validators).contains(customValidator) + .satisfiesOnlyOnce( + (validator) -> assertThat(validator).isInstanceOf(JwtTypeValidator.class))); + }); + } + @SuppressWarnings("unchecked") private void assertFilterConfiguredWithJwtAuthenticationManager(AssertableReactiveWebApplicationContext context) { MatcherSecurityWebFilterChain filterChain = (MatcherSecurityWebFilterChain) context @@ -826,7 +882,7 @@ class ReactiveOAuth2ResourceServerAutoConfigurationTests { DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils .getField(jwtDecoder, "jwtValidator"); assertThat(jwtValidator).isNotNull(); - assertThat(jwtValidator.validate(builder.build()).hasErrors()).isFalse(); + assertThat(jwtValidator.validate(builder.build()).getErrors()).isEmpty(); validatorsConsumer.accept(extractValidators(jwtValidator)); } @@ -934,6 +990,16 @@ class ReactiveOAuth2ResourceServerAutoConfigurationTests { } + @Configuration(proxyBeanMethods = false) + static class CustomJwtTypeValidatorConfig { + + @Bean + JwtTypeValidator customJwtTypeValidator() { + return new JwtTypeValidator("custom-type"); + } + + } + @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @WithResource(name = "public-key-location", content = """ diff --git a/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/servlet/OAuth2ResourceServerAutoConfigurationTests.java b/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/servlet/OAuth2ResourceServerAutoConfigurationTests.java index 166b90ad610..31beee47841 100644 --- a/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/servlet/OAuth2ResourceServerAutoConfigurationTests.java +++ b/module/spring-boot-security-oauth2-resource-server/src/test/java/org/springframework/boot/security/oauth2/server/resource/autoconfigure/servlet/OAuth2ResourceServerAutoConfigurationTests.java @@ -70,10 +70,12 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.FactorGrantedAuthority; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.JoseHeaderNames; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtClaimValidator; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtIssuerValidator; +import org.springframework.security.oauth2.jwt.JwtTypeValidator; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.SupplierJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; @@ -745,6 +747,59 @@ class OAuth2ResourceServerAutoConfigurationTests { .doesNotHaveBean(MANAGEMENT_SECURITY_FILTER_CHAIN_BEAN)); } + @Test + @SuppressWarnings("unchecked") + void customTypeValidatorCanReplaceDefaultWhenUsingIssuerUri() throws Exception { + this.server = new MockWebServer(); + this.server.start(); + String path = "test"; + String issuer = this.server.url(path).toString(); + String cleanIssuerPath = cleanIssuerPath(issuer); + setupMockResponse(cleanIssuerPath); + String issuerUri = "http://" + this.server.getHostName() + ":" + this.server.getPort() + "/" + path; + this.contextRunner.withPropertyValues("spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri) + .withUserConfiguration(CustomJwtTypeValidatorConfig.class) + .run((context) -> { + SupplierJwtDecoder supplierJwtDecoderBean = context.getBean(SupplierJwtDecoder.class); + Supplier jwtDecoderSupplier = (Supplier) ReflectionTestUtils + .getField(supplierJwtDecoderBean, "delegate"); + assertThat(jwtDecoderSupplier).isNotNull(); + JwtDecoder jwtDecoder = jwtDecoderSupplier.get(); + assertThat(context).hasBean("customJwtTypeValidator"); + OAuth2TokenValidator customValidator = (OAuth2TokenValidator) context + .getBean("customJwtTypeValidator"); + validate(jwt().claim("iss", URI.create(issuerUri).toURL()).header(JoseHeaderNames.TYP, "custom-type"), + jwtDecoder, + (validators) -> assertThat(validators).contains(customValidator) + .satisfiesOnlyOnce( + (validator) -> assertThat(validator).isInstanceOf(JwtTypeValidator.class))); + }); + } + + @Test + @SuppressWarnings("unchecked") + void customTypeValidatorCanReplaceDefaultWhenUsingJwkSetUri() throws Exception { + this.server = new MockWebServer(); + this.server.start(); + String path = "test"; + String issuer = this.server.url(path).toString(); + String cleanIssuerPath = cleanIssuerPath(issuer); + setupMockResponse(cleanIssuerPath); + this.contextRunner + .withPropertyValues("spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://jwk-set-uri.com") + .withUserConfiguration(CustomJwtTypeValidatorConfig.class) + .run((context) -> { + JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); + assertThat(context).hasBean("customJwtTypeValidator"); + OAuth2TokenValidator customValidator = (OAuth2TokenValidator) context + .getBean("customJwtTypeValidator"); + validate(jwt().header(JoseHeaderNames.TYP, "custom-type"), jwtDecoder, + (validators) -> assertThat(validators).contains(customValidator) + .satisfiesOnlyOnce( + (validator) -> assertThat(validator).isInstanceOf(JwtTypeValidator.class))); + }); + } + private @Nullable Filter getBearerTokenFilter(AssertableWebApplicationContext context) { FilterChainProxy filterChain = (FilterChainProxy) context.getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN); List filterChains = filterChain.getFilterChains(); @@ -814,7 +869,7 @@ class OAuth2ResourceServerAutoConfigurationTests { DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils .getField(jwtDecoder, "jwtValidator"); assertThat(jwtValidator).isNotNull(); - assertThat(jwtValidator.validate(builder.build()).hasErrors()).isFalse(); + assertThat(jwtValidator.validate(builder.build()).getErrors()).isEmpty(); validatorsConsumer.accept(extractValidators(jwtValidator)); } @@ -904,6 +959,16 @@ class OAuth2ResourceServerAutoConfigurationTests { } + @Configuration(proxyBeanMethods = false) + static class CustomJwtTypeValidatorConfig { + + @Bean + JwtTypeValidator customJwtTypeValidator() { + return new JwtTypeValidator("custom-type"); + } + + } + @Configuration(proxyBeanMethods = false) static class CustomJwtConverterConfig {