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 3a0c0be755..f54f8d15fb 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 @@ -17,16 +17,20 @@ package org.springframework.boot.autoconfigure.security.oauth2.resource.reactive import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.security.oauth2.resource.IssuerUriCondition; import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoders; /** * Configures a {@link ReactiveJwtDecoder} when a JWK Set URI is available. * * @author Madhura Bhave + * @author Artsiom Yudovin */ @Configuration class ReactiveOAuth2ResourceServerJwkConfiguration { @@ -45,4 +49,12 @@ class ReactiveOAuth2ResourceServerJwkConfiguration { return new NimbusReactiveJwtDecoder(this.properties.getJwt().getJwkSetUri()); } + @Bean + @Conditional(IssuerUriCondition.class) + @ConditionalOnMissingBean + public ReactiveJwtDecoder jwtDecoderByIssuerUri() { + return ReactiveJwtDecoders + .fromOidcIssuerLocation(this.properties.getJwt().getIssuerUri()); + } + } 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 de7c57dd05..bcca68b151 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 @@ -15,9 +15,18 @@ */ package org.springframework.boot.autoconfigure.security.oauth2.resource.reactive; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.After; import org.junit.Test; +import org.testcontainers.shaded.com.fasterxml.jackson.core.JsonProcessingException; +import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.FilteredClassLoader; @@ -25,6 +34,9 @@ import org.springframework.boot.test.context.assertj.AssertableReactiveWebApplic import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.config.BeanIds; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; @@ -47,6 +59,7 @@ import static org.mockito.Mockito.mock; * Tests for {@link ReactiveOAuth2ResourceServerAutoConfiguration}. * * @author Madhura Bhave + * @author Artsiom Yudovin */ public class ReactiveOAuth2ResourceServerAutoConfigurationTests { @@ -55,6 +68,15 @@ public class ReactiveOAuth2ResourceServerAutoConfigurationTests { .of(ReactiveOAuth2ResourceServerAutoConfiguration.class)) .withUserConfiguration(TestConfig.class); + private MockWebServer server; + + @After + public void cleanup() throws Exception { + if (this.server != null) { + this.server.shutdown(); + } + } + @Test public void autoConfigurationShouldConfigureResourceServer() { this.contextRunner.withPropertyValues( @@ -66,6 +88,39 @@ public class ReactiveOAuth2ResourceServerAutoConfigurationTests { }); } + @Test + public void autoConfigurationShouldConfigureResourceServerUsingOidcIssuerUri() + throws IOException { + this.server = new MockWebServer(); + this.server.start(); + String issuer = this.server.url("").toString(); + String cleanIssuerPath = cleanIssuerPath(issuer); + setupMockResponse(cleanIssuerPath); + this.contextRunner + .withPropertyValues( + "spring.security.oauth2.resourceserver.jwt.issuer-uri=http://" + + this.server.getHostName() + ":" + this.server.getPort()) + .run((context) -> { + assertThat(context.getBean(ReactiveJwtDecoder.class)) + .isInstanceOf(NimbusReactiveJwtDecoder.class); + assertFilterConfiguredWithJwtAuthenticationManager(context); + }); + } + + @Test + public void autoConfigurationWhenBothSetUriAndIssuerUriPresentShouldUseSetUri() { + this.contextRunner.withPropertyValues( + "spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://jwk-set-uri.com", + "spring.security.oauth2.resourceserver.jwt.issuer-uri=http://jwk-oidc-issuer-location.com") + .run((context) -> { + assertThat(context.getBean(ReactiveJwtDecoder.class)) + .isInstanceOf(NimbusReactiveJwtDecoder.class); + assertFilterConfiguredWithJwtAuthenticationManager(context); + assertThat(context.containsBean("jwtDecoder")).isTrue(); + assertThat(context.containsBean("jwtDecoderByIssuerUri")).isFalse(); + }); + } + @Test public void autoConfigurationWhenJwkSetUriNullShouldNotFail() { this.contextRunner.run((context) -> assertThat(context) @@ -80,6 +135,14 @@ public class ReactiveOAuth2ResourceServerAutoConfigurationTests { .run((this::assertFilterConfiguredWithJwtAuthenticationManager)); } + @Test + public void jwtDecoderByIssuerUriBeanIsConditionalOnMissingBean() { + this.contextRunner.withPropertyValues( + "spring.security.oauth2.resourceserver.jwt.issuer-uri=http://jwk-oidc-issuer-location.com") + .withUserConfiguration(JwtDecoderConfig.class) + .run((this::assertFilterConfiguredWithJwtAuthenticationManager)); + } + @Test public void autoConfigurationShouldBeConditionalOnBearerTokenAuthenticationTokenClass() { this.contextRunner.withPropertyValues( @@ -119,6 +182,42 @@ public class ReactiveOAuth2ResourceServerAutoConfigurationTests { } + private String cleanIssuerPath(String issuer) { + if (issuer.endsWith("/")) { + return issuer.substring(0, issuer.length() - 1); + } + return issuer; + } + + private void setupMockResponse(String issuer) throws JsonProcessingException { + MockResponse mockResponse = new MockResponse() + .setResponseCode(HttpStatus.OK.value()) + .setBody(new ObjectMapper().writeValueAsString(getResponse(issuer))) + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + this.server.enqueue(mockResponse); + } + + private Map getResponse(String issuer) { + Map response = new HashMap<>(); + response.put("authorization_endpoint", "https://example.com/o/oauth2/v2/auth"); + response.put("claims_supported", Collections.emptyList()); + response.put("code_challenge_methods_supported", Collections.emptyList()); + response.put("id_token_signing_alg_values_supported", Collections.emptyList()); + response.put("issuer", issuer); + response.put("jwks_uri", "https://example.com/oauth2/v3/certs"); + response.put("response_types_supported", Collections.emptyList()); + response.put("revocation_endpoint", "https://example.com/o/oauth2/revoke"); + response.put("scopes_supported", Collections.singletonList("openid")); + response.put("subject_types_supported", Collections.singletonList("public")); + response.put("grant_types_supported", + Collections.singletonList("authorization_code")); + response.put("token_endpoint", "https://example.com/oauth2/v4/token"); + response.put("token_endpoint_auth_methods_supported", + Collections.singletonList("client_secret_basic")); + response.put("userinfo_endpoint", "https://example.com/oauth2/v3/userinfo"); + return response; + } + @EnableWebFluxSecurity static class TestConfig {