diff --git a/docs/modules/ROOT/pages/client.adoc b/docs/modules/ROOT/pages/client.adoc index 1358d3c6a..fc37c9bbb 100644 --- a/docs/modules/ROOT/pages/client.adoc +++ b/docs/modules/ROOT/pages/client.adoc @@ -214,6 +214,45 @@ spring: The `spring.cloud.config.password` and `spring.cloud.config.username` values override anything that is provided in the URI. +If your Config Server is secured with OAuth2, the Config Client can attach a bearer token +to every request. Configure the OAuth2 client registration using the standard Spring +Security properties and point the Config Client at it by id: + +[source,yaml] +---- +spring: + cloud: + config: + uri: https://myconfig.mycompany.com + oauth2: + enabled: true + client-registration-id: config-client + security: + oauth2: + client: + registration: + config-client: + client-id: client-id + client-secret: client-secret + authorization-grant-type: client_credentials + provider: + config-client: + token-uri: https://auth.acme.com/oauth/token +---- + +`spring-boot-starter-security-oauth2-client` is an optional dependency. The Config Client +attaches the OAuth2 bearer-token interceptor only when that starter is on the classpath +and `spring.cloud.config.oauth2.enabled=true`; otherwise the interceptor is skipped. This +works for both the Config Data import flow (`spring.config.import=configserver:`) and the +legacy bootstrap flow. By default the Config Client builds an +`InMemoryClientRegistrationRepository` from the properties above and an +`OAuth2AuthorizedClientManager` that supports the `client_credentials` and `refresh_token` +grant types. Each layer is overridable: register your own `ClientRegistrationRepository`, +`OAuth2AuthorizedClientManager`, `ClientRegistrationIdResolver`, or the +`ClientHttpRequestInterceptor` itself (in the bootstrap registry via a +`BootstrapRegistryInitializer` for the Config Data flow, or as a bean for the legacy +bootstrap flow) and the default will step aside. + If you deploy your apps on Cloud Foundry, the best way to provide the password is through service credentials (such as in the URI, since it does not need to be in a config file). The following example works locally and for a user-provided service on Cloud Foundry named `configserver`: diff --git a/pom.xml b/pom.xml index 0848f9644..d00abb868 100644 --- a/pom.xml +++ b/pom.xml @@ -107,6 +107,7 @@ spring-cloud-config-sample spring-cloud-starter-config spring-cloud-config-client-tls-tests + spring-cloud-config-client-oauth2-tests docs @@ -178,6 +179,7 @@ spring-cloud-config-client-tls-tests + spring-cloud-config-client-oauth2-tests spring-cloud-config-sample diff --git a/spring-cloud-config-client-oauth2-tests/pom.xml b/spring-cloud-config-client-oauth2-tests/pom.xml new file mode 100644 index 000000000..dc41b8042 --- /dev/null +++ b/spring-cloud-config-client-oauth2-tests/pom.xml @@ -0,0 +1,107 @@ + + + 4.0.0 + spring-cloud-config-client-oauth2-tests + jar + Spring Cloud Config Client OAuth2 Tests + + + org.springframework.cloud + spring-cloud-config + 5.0.4-SNAPSHOT + .. + + + https://spring.io + Spring Cloud Config Client OAuth2 Integration Tests + + + + org.springframework.cloud + spring-cloud-config-client + ${project.version} + + + org.springframework.boot + spring-boot + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-starter-security-oauth2-client + + + org.springframework + spring-web + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.platform + junit-platform-launcher + test + + + org.springframework.cloud + spring-cloud-test-support + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + com.github.dasniko + testcontainers-keycloak + 3.4.0 + test + + + commons-io + commons-io + 2.20.0 + + + + org.mock-server + mockserver-netty + 5.15.0 + test + + + org.mock-server + mockserver-client-java + 5.15.0 + test + + + + + + + + maven-deploy-plugin + + true + + + + + + diff --git a/spring-cloud-config-client-oauth2-tests/src/test/java/org/springframework/cloud/config/client/ConfigClientRequestTemplateFactoryOAuth2Tests.java b/spring-cloud-config-client-oauth2-tests/src/test/java/org/springframework/cloud/config/client/ConfigClientRequestTemplateFactoryOAuth2Tests.java new file mode 100644 index 000000000..45c079c75 --- /dev/null +++ b/spring-cloud-config-client-oauth2-tests/src/test/java/org/springframework/cloud/config/client/ConfigClientRequestTemplateFactoryOAuth2Tests.java @@ -0,0 +1,244 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.config.client; + +import java.net.URI; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import dasniko.testcontainers.keycloak.KeycloakContainer; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.mockserver.client.MockServerClient; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.MediaType; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.boot.bootstrap.DefaultBootstrapContext; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.cloud.config.client.oauth2.ConfigClientOAuth2Support; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +/** + * IntegrationTest for OAuth2 support in ConfigClientRequestTemplateFactory using Keycloak + * Test container as the Authorization Server and MockServer as a protected resource + * server. + * + *

+ * Each test drives the real wiring entry point + * {@link ConfigClientOAuth2Support#registerInterceptor} with bound properties, exactly as + * the Config Data flow does at runtime. + *

+ */ +@Tag("DockerRequired") +@Testcontainers +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ConfigClientRequestTemplateFactoryOAuth2Tests { + + private static final Log log = LogFactory.getLog(ConfigClientRequestTemplateFactoryOAuth2Tests.class); + + private static final String REGISTRATION_ID = "config-client"; + + @Container + static KeycloakContainer keycloak = new KeycloakContainer().withRealmImportFile("test-realm.json"); // classpath + // resource + + private ClientAndServer mockServer; + + private MockServerClient mockClient; + + @BeforeAll + void startMockServer() { + mockServer = ClientAndServer.startClientAndServer(0); + mockClient = new MockServerClient("localhost", mockServer.getLocalPort()); + } + + @BeforeEach + void resetExpectations() { + mockClient.clear(request().withPath("/secure")); + mockClient.when(request().withMethod("GET").withPath("/secure")).respond(request -> { + if (request.containsHeader("Authorization")) { + String authHeader = request.getFirstHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return response().withStatusCode(200).withContentType(MediaType.TEXT_PLAIN).withBody("ok"); + } + } + return response().withStatusCode(401); + }); + } + + @AfterAll + void tearDown() { + if (mockClient != null) { + mockClient.close(); + } + if (mockServer != null) { + mockServer.stop(); + } + } + + @Test + void restTemplateAddsBearerTokenFromKeycloakUsingClientCredentials() { + // given OAuth2 client configuration pointing to Keycloak token endpoint + String tokenUri = keycloak.getAuthServerUrl() + "/realms/test-realm/protocol/openid-connect/token"; + + Map properties = baseOAuth2Properties(); + properties.put("spring.security.oauth2.client.provider." + REGISTRATION_ID + ".token-uri", tokenUri); + + RestTemplate restTemplate = createRestTemplate(new DefaultBootstrapContext(), properties); + + // when + String url = "http://localhost:" + mockServer.getLocalPort() + "/secure"; + ResponseEntity response = restTemplate.getForEntity(URI.create(url), String.class); + + // then + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()).isEqualTo("ok"); + + HttpRequest[] recorded = mockClient.retrieveRecordedRequests(request().withPath("/secure")); + assertThat(recorded).hasSize(1); + String authHeader = recorded[0].getFirstHeader("Authorization"); + assertThat(authHeader).isNotNull().startsWith("Bearer "); + } + + @Test + void restTemplateAddsBearerTokenFromKeycloakUsingClientCredentialsAndIssuerUri() { + // given OAuth2 client configuration pointing to Keycloak issuer endpoint + String issuerUri = keycloak.getAuthServerUrl() + "/realms/test-realm"; + + Map properties = baseOAuth2Properties(); + properties.put("spring.security.oauth2.client.provider." + REGISTRATION_ID + ".issuer-uri", issuerUri); + + RestTemplate restTemplate = createRestTemplate(new DefaultBootstrapContext(), properties); + + // when + String url = "http://localhost:" + mockServer.getLocalPort() + "/secure"; + ResponseEntity response = restTemplate.getForEntity(URI.create(url), String.class); + + // then + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()).isEqualTo("ok"); + + HttpRequest[] recorded = mockClient.retrieveRecordedRequests(request().withPath("/secure")); + assertThat(recorded).hasSize(1); + String authHeader = recorded[0].getFirstHeader("Authorization"); + assertThat(authHeader).isNotNull().startsWith("Bearer "); + } + + @Test + void restTemplateDoesNotAddAuthorizationHeaderWhenOauth2Disabled() { + // given config-client OAuth2 disabled + Map properties = new HashMap<>(); + properties.put("spring.cloud.config.oauth2.enabled", "false"); + + RestTemplate restTemplate = createRestTemplate(new DefaultBootstrapContext(), properties); + + // when + String url = "http://localhost:" + mockServer.getLocalPort() + "/secure"; + + assertThatThrownBy(() -> restTemplate.getForEntity(URI.create(url), String.class)) + .isInstanceOf(org.springframework.web.client.HttpClientErrorException.Unauthorized.class); + + // then + HttpRequest[] recorded = mockClient.retrieveRecordedRequests(request().withPath("/secure")); + assertThat(recorded).hasSize(1); // only this test's request + assertThat(recorded[0].containsHeader("Authorization")).isFalse(); + } + + @Test + void userSuppliedAuthorizedClientManagerWins() { + // given a bootstrap context with a user-supplied OAuth2AuthorizedClientManager + // already registered — Keycloak is not configured at all, proving the default + // path is bypassed. + Map properties = new HashMap<>(); + properties.put("spring.cloud.config.oauth2.enabled", "true"); + properties.put("spring.cloud.config.oauth2.client-registration-id", REGISTRATION_ID); + + DefaultBootstrapContext bootstrapContext = new DefaultBootstrapContext(); + OAuth2AuthorizedClientManager stubManager = stubManagerReturning("user-supplied-token"); + bootstrapContext.register(OAuth2AuthorizedClientManager.class, ctx -> stubManager); + + RestTemplate restTemplate = createRestTemplate(bootstrapContext, properties); + + // when + String url = "http://localhost:" + mockServer.getLocalPort() + "/secure"; + ResponseEntity response = restTemplate.getForEntity(URI.create(url), String.class); + + // then + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + HttpRequest[] recorded = mockClient.retrieveRecordedRequests(request().withPath("/secure")); + assertThat(recorded).hasSize(1); + assertThat(recorded[0].getFirstHeader("Authorization")).isEqualTo("Bearer user-supplied-token"); + } + + private static Map baseOAuth2Properties() { + Map properties = new HashMap<>(); + properties.put("spring.cloud.config.oauth2.enabled", "true"); + properties.put("spring.cloud.config.oauth2.client-registration-id", REGISTRATION_ID); + properties.put("spring.security.oauth2.client.registration." + REGISTRATION_ID + ".client-id", "config-client"); + properties.put("spring.security.oauth2.client.registration." + REGISTRATION_ID + ".client-secret", + "my-client-secret"); + properties.put("spring.security.oauth2.client.registration." + REGISTRATION_ID + ".authorization-grant-type", + "client_credentials"); + return properties; + } + + private static RestTemplate createRestTemplate(DefaultBootstrapContext bootstrapContext, + Map properties) { + Binder binder = new Binder(new MapConfigurationPropertySource(properties)); + ConfigClientRequestTemplateFactory factory = new ConfigClientRequestTemplateFactory(log, + new ConfigClientProperties()); + ConfigClientOAuth2Support.registerInterceptor(bootstrapContext, binder, null, factory); + return factory.create(); + } + + private static OAuth2AuthorizedClientManager stubManagerReturning(String tokenValue) { + ClientRegistration registration = ClientRegistration.withRegistrationId(REGISTRATION_ID) + .clientId("config-client") + .clientSecret("my-client-secret") + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .tokenUri("http://stub/token") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, tokenValue, + Instant.now(), Instant.now().plusSeconds(60)); + OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(registration, "config-client", + accessToken); + return authorizeRequest -> authorizedClient; + } + +} diff --git a/spring-cloud-config-client-oauth2-tests/src/test/resources/test-realm.json b/spring-cloud-config-client-oauth2-tests/src/test/resources/test-realm.json new file mode 100644 index 000000000..7aaed2819 --- /dev/null +++ b/spring-cloud-config-client-oauth2-tests/src/test/resources/test-realm.json @@ -0,0 +1,18 @@ +{ + "realm": "test-realm", + "enabled": true, + "clients": [ + { + "clientId": "config-client", + "secret": "my-client-secret", + "name": "Config Client", + "protocol": "openid-connect", + "publicClient": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": true, + "redirectUris": ["*"], + "webOrigins": ["*"] + } + ] +} + diff --git a/spring-cloud-config-client/pom.xml b/spring-cloud-config-client/pom.xml index 56d1ad2cd..458fe7538 100644 --- a/spring-cloud-config-client/pom.xml +++ b/spring-cloud-config-client/pom.xml @@ -58,6 +58,11 @@ spring-boot-starter-actuator true + + org.springframework.boot + spring-boot-starter-security-oauth2-client + true + org.springframework.boot spring-boot-starter-aspectj diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientRequestTemplateFactory.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientRequestTemplateFactory.java index 6ceca390f..dfb1ba332 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientRequestTemplateFactory.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientRequestTemplateFactory.java @@ -18,9 +18,11 @@ import java.io.IOException; import java.security.GeneralSecurityException; -import java.util.Arrays; +import java.util.ArrayList; import java.util.Base64; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -54,9 +56,30 @@ public class ConfigClientRequestTemplateFactory { private final ConfigClientProperties properties; + private final List additionalInterceptors = new ArrayList<>(); + public ConfigClientRequestTemplateFactory(Log log, ConfigClientProperties properties) { + this(log, properties, Collections.emptyList()); + } + + public ConfigClientRequestTemplateFactory(Log log, ConfigClientProperties properties, + List additionalInterceptors) { this.log = log; this.properties = properties; + if (additionalInterceptors != null) { + this.additionalInterceptors.addAll(additionalInterceptors); + } + } + + /** + * Add an interceptor to be applied to the {@link RestTemplate} returned by + * {@link #create()}. Used to attach authentication interceptors (for example the + * OAuth2 bearer-token interceptor) without coupling this factory to a specific + * security implementation. + * @param interceptor the interceptor to add + */ + public void addInterceptor(ClientHttpRequestInterceptor interceptor) { + this.additionalInterceptors.add(interceptor); } public Log getLog() { @@ -77,11 +100,15 @@ public RestTemplate create() { ClientHttpRequestFactory requestFactory = createHttpRequestFactory(properties); RestTemplate template = new RestTemplate(requestFactory); + + final List interceptors = new ArrayList<>(); Map headers = new HashMap<>(properties.getHeaders()); headers.remove(AUTHORIZATION); // To avoid redundant addition of header if (!headers.isEmpty()) { - template.setInterceptors(Arrays.asList(new GenericRequestHeaderInterceptor(headers))); + interceptors.add(new GenericRequestHeaderInterceptor(headers)); } + interceptors.addAll(this.additionalInterceptors); + template.setInterceptors(interceptors); return template; } diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java index 5e94ab0a6..78dcb07b2 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServerConfigDataLocationResolver.java @@ -41,6 +41,7 @@ import org.springframework.cloud.bootstrap.encrypt.RsaProperties; import org.springframework.cloud.bootstrap.encrypt.TextEncryptorUtils; import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.config.client.oauth2.ConfigClientOAuth2Support; import org.springframework.cloud.context.encrypt.EncryptorFactory; import org.springframework.core.Ordered; import org.springframework.core.log.LogMessage; @@ -240,8 +241,13 @@ public List resolveProfileSpecific( .registerSingleton("configDataConfigClientProperties", event.getBootstrapContext().get(ConfigClientProperties.class))); - bootstrapContext.registerIfAbsent(ConfigClientRequestTemplateFactory.class, - context -> new ConfigClientRequestTemplateFactory(log, context.get(ConfigClientProperties.class))); + bootstrapContext.registerIfAbsent(ConfigClientRequestTemplateFactory.class, context -> { + ConfigClientRequestTemplateFactory factory = new ConfigClientRequestTemplateFactory(log, + context.get(ConfigClientProperties.class)); + ConfigClientOAuth2Support.registerInterceptor(bootstrapContext, resolverContext.getBinder(), + getBindHandler(resolverContext), factory); + return factory; + }); bootstrapContext.registerIfAbsent(RestTemplate.class, context -> { ConfigClientRequestTemplateFactory factory = context.get(ConfigClientRequestTemplateFactory.class); diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServiceBootstrapConfiguration.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServiceBootstrapConfiguration.java index e3dfeb10d..ecc12090e 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServiceBootstrapConfiguration.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServiceBootstrapConfiguration.java @@ -16,18 +16,24 @@ package org.springframework.cloud.config.client; +import java.util.List; + import org.aspectj.lang.annotation.Aspect; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.config.client.oauth2.ConfigClientOAuth2BootstrapConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.retry.annotation.EnableRetry; import org.springframework.retry.annotation.Retryable; import org.springframework.retry.backoff.ExponentialBackOffPolicy; @@ -57,8 +63,11 @@ public ConfigClientProperties configClientProperties() { @Bean @ConditionalOnMissingBean(ConfigServicePropertySourceLocator.class) @ConditionalOnProperty(name = ConfigClientProperties.PREFIX + ".enabled", matchIfMissing = true) - public ConfigServicePropertySourceLocator configServicePropertySource(ConfigClientProperties properties) { - return new ConfigServicePropertySourceLocator(properties); + public ConfigServicePropertySourceLocator configServicePropertySource(ConfigClientProperties properties, + @Qualifier(ConfigClientOAuth2BootstrapConfiguration.OAUTH2_INTERCEPTOR_BEAN_NAME) ObjectProvider oauth2Interceptor) { + ConfigServicePropertySourceLocator locator = new ConfigServicePropertySourceLocator(properties); + oauth2Interceptor.ifAvailable(interceptor -> locator.setAdditionalInterceptors(List.of(interceptor))); + return locator; } @ConditionalOnProperty(ConfigClientProperties.PREFIX + ".fail-fast") diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServicePropertySourceLocator.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServicePropertySourceLocator.java index b6a00bc62..92bc1a712 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServicePropertySourceLocator.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigServicePropertySourceLocator.java @@ -50,6 +50,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.retry.annotation.Retryable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -76,10 +77,25 @@ public class ConfigServicePropertySourceLocator implements PropertySourceLocator private ConfigClientProperties defaultProperties; + private List additionalInterceptors = Collections.emptyList(); + public ConfigServicePropertySourceLocator(ConfigClientProperties defaultProperties) { this.defaultProperties = defaultProperties; } + /** + * Additional {@link ClientHttpRequestInterceptor}s applied to the + * {@link RestTemplate} built per + * {@link #locate(org.springframework.core.env.Environment)} call. Used by + * {@link ConfigServiceBootstrapConfiguration} to attach the OAuth2 bearer-token + * interceptor when {@code spring-security-oauth2-client} is on the classpath. + * @param additionalInterceptors the interceptors to apply + */ + public void setAdditionalInterceptors(List additionalInterceptors) { + this.additionalInterceptors = (additionalInterceptors != null) ? additionalInterceptors + : Collections.emptyList(); + } + /** * Combine the active and default profiles from the environment. * @param properties config client properties, @@ -124,7 +140,7 @@ public org.springframework.core.env.PropertySource locate(org.springframework CompositePropertySource composite = new OriginTrackedCompositePropertySource("configService"); ConfigClientRequestTemplateFactory requestTemplateFactory = new ConfigClientRequestTemplateFactory(logger, - properties); + properties, this.additionalInterceptors); Exception error = null; String errorBody = null; diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/oauth2/ConfigClientOAuth2BootstrapConfiguration.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/oauth2/ConfigClientOAuth2BootstrapConfiguration.java new file mode 100644 index 000000000..67911c044 --- /dev/null +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/oauth2/ConfigClientOAuth2BootstrapConfiguration.java @@ -0,0 +1,91 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.config.client.oauth2; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientProperties; +import org.springframework.cloud.config.client.oauth2.ConfigClientOAuth2Support.OAuth2InterceptorRegistrar; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor; +import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor.ClientRegistrationIdResolver; + +/** + * Bootstrap configuration for OAuth2 support in the legacy bootstrap flow. + * + *

+ * Spring Security's own auto-configuration does not run inside the Spring Cloud bootstrap + * context, so {@link OAuth2ClientProperties} binding is enabled here explicitly in order + * to build the default {@link ClientRegistrationRepository} from + * {@code spring.security.oauth2.client.*}. + *

+ * + *

+ * Each layer of the chain is a {@link ConditionalOnMissingBean} so a user may override + * any piece (the {@link ClientRegistrationRepository}, the + * {@link OAuth2AuthorizedClientManager}, the {@link ClientRegistrationIdResolver}, or the + * {@link #configClientOAuth2Interceptor} bean itself) without rewriting the rest. + *

+ */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ OAuth2ClientHttpRequestInterceptor.class, ClientRegistrationRepository.class }) +@ConditionalOnProperty(name = ConfigClientOAuth2Properties.PREFIX + ".enabled") +@EnableConfigurationProperties({ ConfigClientOAuth2Properties.class, OAuth2ClientProperties.class }) +public class ConfigClientOAuth2BootstrapConfiguration { + + /** + * Bean name of the OAuth2 {@link ClientHttpRequestInterceptor} consumed by the legacy + * bootstrap flow. + */ + public static final String OAUTH2_INTERCEPTOR_BEAN_NAME = "configClientOAuth2Interceptor"; + + @Bean + @ConditionalOnMissingBean + public ClientRegistrationRepository configClientClientRegistrationRepository(OAuth2ClientProperties properties) { + return OAuth2InterceptorRegistrar.buildClientRegistrationRepository(properties); + } + + @Bean + @ConditionalOnMissingBean + public OAuth2AuthorizedClientManager configClientAuthorizedClientManager( + ClientRegistrationRepository clientRegistrationRepository) { + return OAuth2InterceptorRegistrar.buildAuthorizedClientManager(clientRegistrationRepository); + } + + @Bean + @ConditionalOnMissingBean + public ClientRegistrationIdResolver configClientRegistrationIdResolver(ConfigClientOAuth2Properties properties) { + String clientRegistrationId = OAuth2InterceptorRegistrar + .requireClientRegistrationId(properties.getClientRegistrationId()); + return request -> clientRegistrationId; + } + + @Bean(name = OAUTH2_INTERCEPTOR_BEAN_NAME) + @ConditionalOnMissingBean(name = OAUTH2_INTERCEPTOR_BEAN_NAME) + public ClientHttpRequestInterceptor configClientOAuth2Interceptor( + OAuth2AuthorizedClientManager authorizedClientManager, + ClientRegistrationIdResolver clientRegistrationIdResolver) { + return OAuth2InterceptorRegistrar.buildInterceptor(authorizedClientManager, clientRegistrationIdResolver); + } + +} diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/oauth2/ConfigClientOAuth2Properties.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/oauth2/ConfigClientOAuth2Properties.java new file mode 100644 index 000000000..2f8507669 --- /dev/null +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/oauth2/ConfigClientOAuth2Properties.java @@ -0,0 +1,63 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.config.client.oauth2; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configures OAuth2 token acquisition for outbound calls from the config client to the + * config server. Token acquisition itself is configured under the standard + * {@code spring.security.oauth2.client.*} properties; this class only opts the config + * client in and selects which registration to use. + */ +@ConfigurationProperties(ConfigClientOAuth2Properties.PREFIX) +public class ConfigClientOAuth2Properties { + + /** + * Configuration prefix for config-client OAuth2 properties. + */ + public static final String PREFIX = "spring.cloud.config.oauth2"; + + /** + * Whether to attach an OAuth2 bearer token to outbound config-client requests. + */ + private boolean enabled = false; + + /** + * Spring Security client registration id (as configured under + * {@code spring.security.oauth2.client.registration.}) to use when fetching the + * access token. + */ + private String clientRegistrationId; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getClientRegistrationId() { + return clientRegistrationId; + } + + public void setClientRegistrationId(String clientRegistrationId) { + this.clientRegistrationId = clientRegistrationId; + } + +} diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/oauth2/ConfigClientOAuth2Support.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/oauth2/ConfigClientOAuth2Support.java new file mode 100644 index 000000000..5a23a7719 --- /dev/null +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/oauth2/ConfigClientOAuth2Support.java @@ -0,0 +1,188 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.config.client.oauth2; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.boot.bootstrap.ConfigurableBootstrapContext; +import org.springframework.boot.context.properties.bind.BindHandler; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientProperties; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientPropertiesMapper; +import org.springframework.cloud.config.client.ConfigClientRequestTemplateFactory; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.InMemoryOAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor; +import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor.ClientRegistrationIdResolver; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +/** + * Wires the OAuth2 {@link ClientHttpRequestInterceptor} into a + * {@link ConfigClientRequestTemplateFactory} for the Spring Cloud Config Data flow, when + * Spring Security OAuth2 client classes are on the classpath and + * {@code spring.cloud.config.oauth2.enabled=true}. + * + *

+ * Every collaborator that gets built ({@link ClientRegistrationRepository}, + * {@link OAuth2AuthorizedClientManager}, {@link ClientRegistrationIdResolver}, and the + * interceptor itself) is looked up in the bootstrap context first via + * {@link ConfigurableBootstrapContext#getOrElseSupply}, so users can plug in custom + * implementations by registering them in the bootstrap registry before this support class + * runs. + *

+ * + *

+ * All Spring Security references are isolated in the nested + * {@link OAuth2InterceptorRegistrar}, which is only loaded after the + * {@link #OAUTH2_CLIENT_IS_PRESENT} guard passes, so config clients without Spring + * Security OAuth2 on the classpath never trigger class loading of those types. + *

+ */ +public final class ConfigClientOAuth2Support { + + /** + * Fully-qualified class name of the OAuth2 request interceptor used as the classpath + * probe. + */ + public static final String OAUTH2_INTERCEPTOR_CLASS = "org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor"; + + /** + * Whether Spring Security OAuth2 client classes are on the classpath. + */ + public static final boolean OAUTH2_CLIENT_IS_PRESENT = ClassUtils.isPresent(OAUTH2_INTERCEPTOR_CLASS, null); + + private ConfigClientOAuth2Support() { + } + + /** + * Adds an OAuth2 interceptor to the given factory if Spring Security OAuth2 client is + * available and {@code spring.cloud.config.oauth2.enabled=true}. Otherwise this is a + * no-op. + * @param bootstrapContext the bootstrap context used to look up overridable + * collaborators + * @param binder the binder used to read OAuth2 properties + * @param bindHandler the bind handler (may be {@code null}) so encrypted or + * placeholder values resolve consistently with the rest of the config client + * @param factory the factory to add the interceptor to + */ + public static void registerInterceptor(ConfigurableBootstrapContext bootstrapContext, Binder binder, + BindHandler bindHandler, ConfigClientRequestTemplateFactory factory) { + if (!OAUTH2_CLIENT_IS_PRESENT) { + return; + } + OAuth2InterceptorRegistrar.register(bootstrapContext, binder, bindHandler, factory); + } + + /** + * Holds every Spring Security reference. Loaded only after the + * {@link #OAUTH2_CLIENT_IS_PRESENT} guard in {@link #registerInterceptor}, or from + * the {@code @ConditionalOnClass}-guarded bootstrap configuration, so config clients + * without Spring Security OAuth2 on the classpath never trigger class loading here. + */ + static final class OAuth2InterceptorRegistrar { + + private OAuth2InterceptorRegistrar() { + } + + static void register(ConfigurableBootstrapContext bootstrapContext, Binder binder, BindHandler bindHandler, + ConfigClientRequestTemplateFactory factory) { + ConfigClientOAuth2Properties oauth2Properties = binder + .bind(ConfigClientOAuth2Properties.PREFIX, Bindable.of(ConfigClientOAuth2Properties.class), bindHandler) + .orElseGet(ConfigClientOAuth2Properties::new); + if (!oauth2Properties.isEnabled()) { + return; + } + String clientRegistrationId = requireClientRegistrationId(oauth2Properties.getClientRegistrationId()); + + // Resolve the manager first. The default ClientRegistrationRepository is + // built + // lazily inside the manager supplier, so a user-supplied manager makes the + // default repository unnecessary. + OAuth2AuthorizedClientManager authorizedClientManager = bootstrapContext.getOrElseSupply( + OAuth2AuthorizedClientManager.class, + () -> buildAuthorizedClientManager( + bootstrapContext.getOrElseSupply(ClientRegistrationRepository.class, + () -> buildClientRegistrationRepository(binder, bindHandler)))); + + ClientRegistrationIdResolver registrationIdResolver = bootstrapContext + .getOrElseSupply(ClientRegistrationIdResolver.class, () -> request -> clientRegistrationId); + + ClientHttpRequestInterceptor interceptor = bootstrapContext.getOrElseSupply( + ClientHttpRequestInterceptor.class, + () -> buildInterceptor(authorizedClientManager, registrationIdResolver)); + + factory.addInterceptor(interceptor); + } + + static String requireClientRegistrationId(String clientRegistrationId) { + if (!StringUtils.hasText(clientRegistrationId)) { + throw new IllegalStateException("'" + ConfigClientOAuth2Properties.PREFIX + ".enabled' is true but '" + + ConfigClientOAuth2Properties.PREFIX + ".client-registration-id' is not set"); + } + return clientRegistrationId; + } + + static ClientRegistrationRepository buildClientRegistrationRepository(Binder binder, BindHandler bindHandler) { + OAuth2ClientProperties oauth2ClientProperties = binder + .bind("spring.security.oauth2.client", Bindable.of(OAuth2ClientProperties.class), bindHandler) + .orElseGet(OAuth2ClientProperties::new); + return buildClientRegistrationRepository(oauth2ClientProperties); + } + + static ClientRegistrationRepository buildClientRegistrationRepository( + OAuth2ClientProperties oauth2ClientProperties) { + oauth2ClientProperties.afterPropertiesSet(); + List registrations = new ArrayList<>( + new OAuth2ClientPropertiesMapper(oauth2ClientProperties).asClientRegistrations().values()); + return new InMemoryClientRegistrationRepository(registrations); + } + + static OAuth2AuthorizedClientManager buildAuthorizedClientManager( + ClientRegistrationRepository registrationRepository) { + OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder() + .clientCredentials() + .refreshToken() + .build(); + OAuth2AuthorizedClientService authorizedClientService = new InMemoryOAuth2AuthorizedClientService( + registrationRepository); + AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager = new AuthorizedClientServiceOAuth2AuthorizedClientManager( + registrationRepository, authorizedClientService); + authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider); + return authorizedClientManager; + } + + static ClientHttpRequestInterceptor buildInterceptor(OAuth2AuthorizedClientManager manager, + ClientRegistrationIdResolver resolver) { + OAuth2ClientHttpRequestInterceptor interceptor = new OAuth2ClientHttpRequestInterceptor(manager); + interceptor.setClientRegistrationIdResolver(resolver); + return interceptor; + } + + } + +} diff --git a/spring-cloud-config-client/src/main/resources/META-INF/spring.factories b/spring-cloud-config-client/src/main/resources/META-INF/spring.factories index ef12ccda1..482d2753f 100644 --- a/spring-cloud-config-client/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-config-client/src/main/resources/META-INF/spring.factories @@ -1,6 +1,7 @@ # Bootstrap components org.springframework.cloud.bootstrap.BootstrapConfiguration=\ org.springframework.cloud.config.client.ConfigServiceBootstrapConfiguration,\ +org.springframework.cloud.config.client.oauth2.ConfigClientOAuth2BootstrapConfiguration,\ org.springframework.cloud.config.client.DiscoveryClientConfigServiceBootstrapConfiguration # Environment PostProcessor