diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/OAuth2ResourceServerProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/OAuth2ResourceServerProperties.java index acf7fbc2356..8b06de17db2 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/OAuth2ResourceServerProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/OAuth2ResourceServerProperties.java @@ -19,6 +19,8 @@ package org.springframework.boot.autoconfigure.security.oauth2.resource; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException; @@ -31,6 +33,7 @@ import org.springframework.util.StreamUtils; * * @author Madhura Bhave * @author Artsiom Yudovin + * @author Mushtaq Ahmed * @since 2.1.0 */ @ConfigurationProperties(prefix = "spring.security.oauth2.resourceserver") @@ -71,6 +74,11 @@ public class OAuth2ResourceServerProperties { */ private Resource publicKeyLocation; + /** + * Identifies the recipients that the JWT is intended for. + */ + private List audiences = new ArrayList<>(); + public String getJwkSetUri() { return this.jwkSetUri; } @@ -103,6 +111,14 @@ public class OAuth2ResourceServerProperties { this.publicKeyLocation = publicKeyLocation; } + public List getAudiences() { + return this.audiences; + } + + public void setAudiences(List audiences) { + this.audiences = audiences; + } + public String readPublicKey() throws IOException { String key = "spring.security.oauth2.resourceserver.public-key-location"; Assert.notNull(this.publicKeyLocation, "PublicKeyLocation must not be null"); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java index 20a3c3a5eab..4daf3f2a53f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerJwkConfiguration.java @@ -19,7 +19,11 @@ package org.springframework.boot.autoconfigure.security.oauth2.resource.reactive import java.security.KeyFactory; import java.security.interfaces.RSAPublicKey; import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -32,13 +36,19 @@ 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.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoders; import org.springframework.security.oauth2.jwt.SupplierReactiveJwtDecoder; import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.util.CollectionUtils; /** * Configures a {@link ReactiveJwtDecoder} when a JWK Set URI, OpenID Connect Issuer URI @@ -49,6 +59,7 @@ import org.springframework.security.web.server.SecurityWebFilterChain; * @author Artsiom Yudovin * @author HaiTao Zhang * @author Anastasiia Losieva + * @author Mushtaq Ahmed */ @Configuration(proxyBeanMethods = false) class ReactiveOAuth2ResourceServerJwkConfiguration { @@ -70,19 +81,34 @@ class ReactiveOAuth2ResourceServerJwkConfiguration { .withJwkSetUri(this.properties.getJwkSetUri()) .jwsAlgorithm(SignatureAlgorithm.from(this.properties.getJwsAlgorithm())).build(); String issuerUri = this.properties.getIssuerUri(); - if (issuerUri != null) { - nimbusReactiveJwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerUri)); - } + Supplier> defaultValidator = (issuerUri != null) + ? () -> JwtValidators.createDefaultWithIssuer(issuerUri) : JwtValidators::createDefault; + nimbusReactiveJwtDecoder.setJwtValidator(getValidators(defaultValidator)); return nimbusReactiveJwtDecoder; } + private OAuth2TokenValidator getValidators(Supplier> defaultValidator) { + OAuth2TokenValidator defaultValidators = defaultValidator.get(); + List audiences = this.properties.getAudiences(); + if (CollectionUtils.isEmpty(audiences)) { + return defaultValidators; + } + List> validators = new ArrayList<>(); + validators.add(defaultValidators); + validators.add(new JwtClaimValidator>(JwtClaimNames.AUD, + (aud) -> aud != null && !Collections.disjoint(aud, audiences))); + return new DelegatingOAuth2TokenValidator<>(validators); + } + @Bean @Conditional(KeyValueCondition.class) NimbusReactiveJwtDecoder jwtDecoderByPublicKeyValue() throws Exception { RSAPublicKey publicKey = (RSAPublicKey) KeyFactory.getInstance("RSA") .generatePublic(new X509EncodedKeySpec(getKeySpec(this.properties.readPublicKey()))); - return NimbusReactiveJwtDecoder.withPublicKey(publicKey) + NimbusReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withPublicKey(publicKey) .signatureAlgorithm(SignatureAlgorithm.from(this.properties.getJwsAlgorithm())).build(); + jwtDecoder.setJwtValidator(getValidators(JwtValidators::createDefault)); + return jwtDecoder; } private byte[] getKeySpec(String keyValue) { @@ -93,8 +119,13 @@ class ReactiveOAuth2ResourceServerJwkConfiguration { @Bean @Conditional(IssuerUriCondition.class) SupplierReactiveJwtDecoder jwtDecoderByIssuerUri() { - return new SupplierReactiveJwtDecoder( - () -> ReactiveJwtDecoders.fromIssuerLocation(this.properties.getIssuerUri())); + return new SupplierReactiveJwtDecoder(() -> { + NimbusReactiveJwtDecoder jwtDecoder = (NimbusReactiveJwtDecoder) ReactiveJwtDecoders + .fromIssuerLocation(this.properties.getIssuerUri()); + jwtDecoder.setJwtValidator( + getValidators(() -> JwtValidators.createDefaultWithIssuer(this.properties.getIssuerUri()))); + return jwtDecoder; + }); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerJwtConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerJwtConfiguration.java index 30873b3377f..14d37c2bee7 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerJwtConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerJwtConfiguration.java @@ -19,7 +19,11 @@ package org.springframework.boot.autoconfigure.security.oauth2.resource.servlet; import java.security.KeyFactory; import java.security.interfaces.RSAPublicKey; import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -33,13 +37,19 @@ 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.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; +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.JwtDecoders; import org.springframework.security.oauth2.jwt.JwtValidators; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.SupplierJwtDecoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.util.CollectionUtils; /** * Configures a {@link JwtDecoder} when a JWK Set URI, OpenID Connect Issuer URI or Public @@ -49,6 +59,7 @@ import org.springframework.security.web.SecurityFilterChain; * @author Madhura Bhave * @author Artsiom Yudovin * @author HaiTao Zhang + * @author Mushtaq Ahmed */ @Configuration(proxyBeanMethods = false) class OAuth2ResourceServerJwtConfiguration { @@ -69,19 +80,34 @@ class OAuth2ResourceServerJwtConfiguration { NimbusJwtDecoder nimbusJwtDecoder = NimbusJwtDecoder.withJwkSetUri(this.properties.getJwkSetUri()) .jwsAlgorithm(SignatureAlgorithm.from(this.properties.getJwsAlgorithm())).build(); String issuerUri = this.properties.getIssuerUri(); - if (issuerUri != null) { - nimbusJwtDecoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerUri)); - } + Supplier> defaultValidator = (issuerUri != null) + ? () -> JwtValidators.createDefaultWithIssuer(issuerUri) : JwtValidators::createDefault; + nimbusJwtDecoder.setJwtValidator(getValidators(defaultValidator)); return nimbusJwtDecoder; } + private OAuth2TokenValidator getValidators(Supplier> defaultValidator) { + OAuth2TokenValidator defaultValidators = defaultValidator.get(); + List audiences = this.properties.getAudiences(); + if (CollectionUtils.isEmpty(audiences)) { + return defaultValidators; + } + List> validators = new ArrayList<>(); + validators.add(defaultValidators); + validators.add(new JwtClaimValidator>(JwtClaimNames.AUD, + (aud) -> aud != null && !Collections.disjoint(aud, audiences))); + return new DelegatingOAuth2TokenValidator<>(validators); + } + @Bean @Conditional(KeyValueCondition.class) JwtDecoder jwtDecoderByPublicKeyValue() throws Exception { RSAPublicKey publicKey = (RSAPublicKey) KeyFactory.getInstance("RSA") .generatePublic(new X509EncodedKeySpec(getKeySpec(this.properties.readPublicKey()))); - return NimbusJwtDecoder.withPublicKey(publicKey) + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(publicKey) .signatureAlgorithm(SignatureAlgorithm.from(this.properties.getJwsAlgorithm())).build(); + jwtDecoder.setJwtValidator(getValidators(JwtValidators::createDefault)); + return jwtDecoder; } private byte[] getKeySpec(String keyValue) { @@ -92,7 +118,12 @@ class OAuth2ResourceServerJwtConfiguration { @Bean @Conditional(IssuerUriCondition.class) SupplierJwtDecoder jwtDecoderByIssuerUri() { - return new SupplierJwtDecoder(() -> JwtDecoders.fromIssuerLocation(this.properties.getIssuerUri())); + return new SupplierJwtDecoder(() -> { + String issuerUri = this.properties.getIssuerUri(); + NimbusJwtDecoder jwtDecoder = JwtDecoders.fromIssuerLocation(issuerUri); + jwtDecoder.setJwtValidator(getValidators(() -> JwtValidators.createDefaultWithIssuer(issuerUri))); + return jwtDecoder; + }); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java index 73d7a39cc8c..b9ea08ab111 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/reactive/ReactiveOAuth2ResourceServerAutoConfigurationTests.java @@ -17,7 +17,10 @@ package org.springframework.boot.autoconfigure.security.oauth2.resource.reactive; import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; import java.time.Duration; +import java.time.Instant; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -51,7 +54,9 @@ 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.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; import org.springframework.security.oauth2.jwt.JwtIssuerValidator; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.SupplierReactiveJwtDecoder; @@ -75,6 +80,7 @@ import static org.mockito.Mockito.mock; * @author Artsiom Yudovin * @author HaiTao Zhang * @author Anastasiia Losieva + * @author Mushtaq Ahmed */ class ReactiveOAuth2ResourceServerAutoConfigurationTests { @@ -390,6 +396,144 @@ class ReactiveOAuth2ResourceServerAutoConfigurationTests { }); } + @SuppressWarnings("unchecked") + @Test + void autoConfigurationShouldNotConfigureIssuerUriAndAudienceJwtValidatorIfPropertyNotConfigured() 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") + .run((context) -> { + assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); + ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class); + DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils + .getField(reactiveJwtDecoder, "jwtValidator"); + Collection> tokenValidators = (Collection>) ReflectionTestUtils + .getField(jwtValidator, "tokenValidators"); + assertThat(tokenValidators).hasExactlyElementsOfTypes(JwtTimestampValidator.class); + assertThat(tokenValidators).doesNotHaveAnyElementsOfTypes(JwtClaimValidator.class); + assertThat(tokenValidators).doesNotHaveAnyElementsOfTypes(JwtIssuerValidator.class); + }); + } + + @SuppressWarnings("unchecked") + @Test + void autoConfigurationShouldConfigureIssuerAndAudienceJwtValidatorIfPropertyProvided() 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.jwk-set-uri=https://jwk-set-uri.com", + "spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri, + "spring.security.oauth2.resourceserver.jwt.audiences=https://test-audience.com,https://test-audience1.com") + .run((context) -> { + assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); + ReactiveJwtDecoder reactiveJwtDecoder = context.getBean(ReactiveJwtDecoder.class); + validate(issuerUri, reactiveJwtDecoder); + }); + } + + @SuppressWarnings("unchecked") + private void validate(String issuerUri, ReactiveJwtDecoder jwtDecoder) throws MalformedURLException { + DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils + .getField(jwtDecoder, "jwtValidator"); + Jwt.Builder builder = jwt().claim("aud", Collections.singletonList("https://test-audience.com")); + if (issuerUri != null) { + builder.claim("iss", new URL(issuerUri)); + } + Jwt jwt = builder.build(); + assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse(); + Collection> delegates = (Collection>) ReflectionTestUtils + .getField(jwtValidator, "tokenValidators"); + validateDelegates(issuerUri, delegates); + } + + @SuppressWarnings("unchecked") + private void validateDelegates(String issuerUri, Collection> delegates) { + assertThat(delegates).hasAtLeastOneElementOfType(JwtClaimValidator.class); + OAuth2TokenValidator delegatingValidator = delegates.stream() + .filter((v) -> v instanceof DelegatingOAuth2TokenValidator).findFirst().get(); + Collection> nestedDelegates = (Collection>) ReflectionTestUtils + .getField(delegatingValidator, "tokenValidators"); + if (issuerUri != null) { + assertThat(nestedDelegates).hasAtLeastOneElementOfType(JwtIssuerValidator.class); + } + } + + @SuppressWarnings("unchecked") + @Test + void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndIssuerUri() 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, + "spring.security.oauth2.resourceserver.jwt.audiences=https://test-audience.com,https://test-audience1.com") + .run((context) -> { + SupplierReactiveJwtDecoder supplierJwtDecoderBean = context + .getBean(SupplierReactiveJwtDecoder.class); + Mono jwtDecoderSupplier = (Mono) ReflectionTestUtils + .getField(supplierJwtDecoderBean, "jwtDecoderMono"); + ReactiveJwtDecoder jwtDecoder = jwtDecoderSupplier.block(); + validate(issuerUri, jwtDecoder); + }); + } + + @SuppressWarnings("unchecked") + @Test + void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndPublicKey() 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.public-key-location=classpath:public-key-location", + "spring.security.oauth2.resourceserver.jwt.audiences=https://test-audience.com,https://test-audience1.com") + .run((context) -> { + assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); + ReactiveJwtDecoder jwtDecoder = context.getBean(ReactiveJwtDecoder.class); + validate(null, jwtDecoder); + }); + } + + @SuppressWarnings("unchecked") + @Test + void audienceValidatorWhenAudienceInvalid() 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.jwk-set-uri=https://jwk-set-uri.com", + "spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri, + "spring.security.oauth2.resourceserver.jwt.audiences=https://test-audience.com,https://test-audience1.com") + .run((context) -> { + assertThat(context).hasSingleBean(ReactiveJwtDecoder.class); + ReactiveJwtDecoder jwtDecoder = context.getBean(ReactiveJwtDecoder.class); + DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils + .getField(jwtDecoder, "jwtValidator"); + Jwt jwt = jwt().claim("iss", new URL(issuerUri)) + .claim("aud", Collections.singletonList("https://other-audience.com")).build(); + assertThat(jwtValidator.validate(jwt).hasErrors()).isTrue(); + }); + } + private void assertFilterConfiguredWithJwtAuthenticationManager(AssertableReactiveWebApplicationContext context) { MatcherSecurityWebFilterChain filterChain = (MatcherSecurityWebFilterChain) context .getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN); @@ -458,6 +602,19 @@ class ReactiveOAuth2ResourceServerAutoConfigurationTests { return response; } + static Jwt.Builder jwt() { + // @formatter:off + return Jwt.withTokenValue("token") + .header("alg", "none") + .expiresAt(Instant.MAX) + .issuedAt(Instant.MIN) + .issuer("https://issuer.example.org") + .jti("jti") + .notBefore(Instant.MIN) + .subject("mock-test-subject"); + // @formatter:on + } + @EnableWebFluxSecurity static class TestConfig { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerAutoConfigurationTests.java index a7e0fa6854f..376a31597ee 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/security/oauth2/resource/servlet/OAuth2ResourceServerAutoConfigurationTests.java @@ -16,6 +16,9 @@ package org.springframework.boot.autoconfigure.security.oauth2.resource.servlet; +import java.net.MalformedURLException; +import java.net.URL; +import java.time.Instant; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -47,8 +50,10 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidator; 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.JwtTimestampValidator; import org.springframework.security.oauth2.jwt.SupplierJwtDecoder; import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; @@ -67,6 +72,7 @@ import static org.mockito.Mockito.mock; * @author Madhura Bhave * @author Artsiom Yudovin * @author HaiTao Zhang + * @author Mushtaq Ahmed */ class OAuth2ResourceServerAutoConfigurationTests { @@ -403,6 +409,143 @@ class OAuth2ResourceServerAutoConfigurationTests { }); } + @SuppressWarnings("unchecked") + @Test + void autoConfigurationShouldNotConfigureIssuerUriAndAudienceJwtValidatorIfPropertyNotConfigured() 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") + .run((context) -> { + assertThat(context).hasSingleBean(JwtDecoder.class); + JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); + DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils + .getField(jwtDecoder, "jwtValidator"); + Collection> tokenValidators = (Collection>) ReflectionTestUtils + .getField(jwtValidator, "tokenValidators"); + assertThat(tokenValidators).hasExactlyElementsOfTypes(JwtTimestampValidator.class); + assertThat(tokenValidators).doesNotHaveAnyElementsOfTypes(JwtClaimValidator.class); + assertThat(tokenValidators).doesNotHaveAnyElementsOfTypes(JwtIssuerValidator.class); + }); + } + + @SuppressWarnings("unchecked") + @Test + void autoConfigurationShouldConfigureAudienceAndIssuerJwtValidatorIfPropertyProvided() 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.jwk-set-uri=https://jwk-set-uri.com", + "spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri, + "spring.security.oauth2.resourceserver.jwt.audiences=https://test-audience.com,https://test-audience1.com") + .run((context) -> { + assertThat(context).hasSingleBean(JwtDecoder.class); + JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); + validate(issuerUri, jwtDecoder); + }); + } + + @SuppressWarnings("unchecked") + @Test + void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndIssuerUri() 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, + "spring.security.oauth2.resourceserver.jwt.audiences=https://test-audience.com,https://test-audience1.com") + .run((context) -> { + SupplierJwtDecoder supplierJwtDecoderBean = context.getBean(SupplierJwtDecoder.class); + Supplier jwtDecoderSupplier = (Supplier) ReflectionTestUtils + .getField(supplierJwtDecoderBean, "jwtDecoderSupplier"); + JwtDecoder jwtDecoder = jwtDecoderSupplier.get(); + validate(issuerUri, jwtDecoder); + }); + } + + @SuppressWarnings("unchecked") + private void validate(String issuerUri, JwtDecoder jwtDecoder) throws MalformedURLException { + DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils + .getField(jwtDecoder, "jwtValidator"); + Jwt.Builder builder = jwt().claim("aud", Collections.singletonList("https://test-audience.com")); + if (issuerUri != null) { + builder.claim("iss", new URL(issuerUri)); + } + Jwt jwt = builder.build(); + assertThat(jwtValidator.validate(jwt).hasErrors()).isFalse(); + Collection> delegates = (Collection>) ReflectionTestUtils + .getField(jwtValidator, "tokenValidators"); + validateDelegates(issuerUri, delegates); + } + + @SuppressWarnings("unchecked") + private void validateDelegates(String issuerUri, Collection> delegates) { + assertThat(delegates).hasAtLeastOneElementOfType(JwtClaimValidator.class); + OAuth2TokenValidator delegatingValidator = delegates.stream() + .filter((v) -> v instanceof DelegatingOAuth2TokenValidator).findFirst().get(); + Collection> nestedDelegates = (Collection>) ReflectionTestUtils + .getField(delegatingValidator, "tokenValidators"); + if (issuerUri != null) { + assertThat(nestedDelegates).hasAtLeastOneElementOfType(JwtIssuerValidator.class); + } + } + + @SuppressWarnings("unchecked") + @Test + void autoConfigurationShouldConfigureAudienceValidatorIfPropertyProvidedAndPublicKey() 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.public-key-location=classpath:public-key-location", + "spring.security.oauth2.resourceserver.jwt.audiences=https://test-audience.com,http://test-audience1.com") + .run((context) -> { + assertThat(context).hasSingleBean(JwtDecoder.class); + JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); + validate(null, jwtDecoder); + }); + } + + @SuppressWarnings("unchecked") + @Test + void audienceValidatorWhenAudienceInvalid() 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.jwk-set-uri=https://jwk-set-uri.com", + "spring.security.oauth2.resourceserver.jwt.issuer-uri=" + issuerUri, + "spring.security.oauth2.resourceserver.jwt.audiences=https://test-audience.com,https://test-audience1.com") + .run((context) -> { + assertThat(context).hasSingleBean(JwtDecoder.class); + JwtDecoder jwtDecoder = context.getBean(JwtDecoder.class); + DelegatingOAuth2TokenValidator jwtValidator = (DelegatingOAuth2TokenValidator) ReflectionTestUtils + .getField(jwtDecoder, "jwtValidator"); + Jwt jwt = jwt().claim("iss", new URL(issuerUri)) + .claim("aud", Collections.singletonList("https://other-audience.com")).build(); + assertThat(jwtValidator.validate(jwt).hasErrors()).isTrue(); + }); + } + @Test void jwtSecurityConfigurerBacksOffWhenSecurityFilterChainBeanIsPresent() { this.contextRunner @@ -471,6 +614,19 @@ class OAuth2ResourceServerAutoConfigurationTests { return response; } + static Jwt.Builder jwt() { + // @formatter:off + return Jwt.withTokenValue("token") + .header("alg", "none") + .expiresAt(Instant.MAX) + .issuedAt(Instant.MIN) + .issuer("https://issuer.example.org") + .jti("jti") + .notBefore(Instant.MIN) + .subject("mock-test-subject"); + // @formatter:on + } + @Configuration(proxyBeanMethods = false) @EnableWebSecurity static class TestConfig {