diff --git a/docs/modules/ROOT/pages/client.adoc b/docs/modules/ROOT/pages/client.adoc index 1358d3c6a1..e173c46221 100644 --- a/docs/modules/ROOT/pages/client.adoc +++ b/docs/modules/ROOT/pages/client.adoc @@ -214,6 +214,27 @@ spring: The `spring.cloud.config.password` and `spring.cloud.config.username` values override anything that is provided in the URI. +If you use OAuth2 security on the server, clients need to know the client ID and client secret. +You can specify the client ID and client secret via separate properties, as shown in the following example: + +[source,yaml] +---- + +spring: + cloud: + config: + uri: https://myconfig.mycompany.com + oauth2: + enabled: true + provider: + token-uri: https://auth.acme.com/oauth/token + registration: + client-id: client-id + client-secret: client-secret + authorization-grant-type: client_credentials + +---- + 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 bcb448d399..e0fb8a5f15 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 0000000000..840ab8398e --- /dev/null +++ b/spring-cloud-config-client-oauth2-tests/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + spring-cloud-config-client-oauth2-tests + jar + Spring Cloud Config Client OAuth2 Tests + + + org.springframework.cloud + spring-cloud-config + 5.0.0-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 + 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 0000000000..221f95f156 --- /dev/null +++ b/spring-cloud-config-client-oauth2-tests/src/test/java/org/springframework/cloud/config/client/ConfigClientRequestTemplateFactoryOAuth2Tests.java @@ -0,0 +1,184 @@ +/* + * 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 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.security.oauth2.client.autoconfigure.OAuth2ClientProperties; +import org.springframework.http.ResponseEntity; +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. + */ +@Tag("DockerRequired") +@Testcontainers +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ConfigClientRequestTemplateFactoryOAuth2Tests { + + private static final Log log = LogFactory.getLog(ConfigClientRequestTemplateFactoryOAuth2Tests.class); + + @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"; + + ConfigClientProperties props = new ConfigClientProperties(); + props.getOauth2().setEnabled(true); + + OAuth2ClientProperties.Provider provider = props.getOauth2().getProvider(); + provider.setTokenUri(tokenUri); + + OAuth2ClientProperties.Registration registration = props.getOauth2().getRegistration(); + registration.setClientId("config-client"); + registration.setClientSecret("my-client-secret"); + registration.setAuthorizationGrantType("client_credentials"); + + ConfigClientRequestTemplateFactory factory = new ConfigClientRequestTemplateFactory(log, props); + RestTemplate restTemplate = factory.create(); + + // 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"; + + ConfigClientProperties props = new ConfigClientProperties(); + props.getOauth2().setEnabled(true); + + OAuth2ClientProperties.Provider provider = props.getOauth2().getProvider(); + provider.setIssuerUri(issuerUri); + + OAuth2ClientProperties.Registration registration = props.getOauth2().getRegistration(); + registration.setClientId("config-client"); + registration.setClientSecret("my-client-secret"); + registration.setAuthorizationGrantType("client_credentials"); + + ConfigClientRequestTemplateFactory factory = new ConfigClientRequestTemplateFactory(log, props); + RestTemplate restTemplate = factory.create(); + + // 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 ConfigClientProperties with OAuth2 disabled + ConfigClientProperties props = new ConfigClientProperties(); + props.getOauth2().setEnabled(false); // explicitly disabled + + ConfigClientRequestTemplateFactory factory = new ConfigClientRequestTemplateFactory(log, props); + RestTemplate restTemplate = factory.create(); + + // 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(); + } + +} 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 0000000000..7aaed2819b --- /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 825b438124..ccb71fba9a 100644 --- a/spring-cloud-config-client/pom.xml +++ b/spring-cloud-config-client/pom.xml @@ -58,6 +58,10 @@ spring-boot-starter-actuator true + + org.springframework.boot + spring-boot-starter-security-oauth2-client + org.springframework.boot spring-boot-starter-aspectj diff --git a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientProperties.java b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientProperties.java index 28bfb3fd3b..816e60d2d6 100644 --- a/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientProperties.java +++ b/spring-cloud-config-client/src/main/java/org/springframework/cloud/config/client/ConfigClientProperties.java @@ -28,6 +28,7 @@ import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientProperties; import org.springframework.cloud.config.environment.EnvironmentMediaType; import org.springframework.cloud.configuration.TlsProperties; import org.springframework.core.env.Environment; @@ -185,6 +186,8 @@ public class ConfigClientProperties { */ private boolean sendAllLabels = false; + private OAuth2Properties oauth2 = new OAuth2Properties(); + ConfigClientProperties() { } @@ -352,6 +355,14 @@ public void setSendAllLabels(boolean sendAllLabels) { this.sendAllLabels = sendAllLabels; } + public OAuth2Properties getOauth2() { + return oauth2; + } + + public void setOauth2(OAuth2Properties oauth2) { + this.oauth2 = oauth2; + } + private Credentials extractCredentials(int index) { Credentials result = new Credentials(); int noOfUrl = this.uri.length; @@ -441,7 +452,8 @@ public String toString() { + Arrays.toString(this.uri) + ", mediaType=" + this.mediaType + ", discovery=" + this.discovery + ", failFast=" + this.failFast + ", token=" + this.token + ", requestConnectTimeout=" + this.requestConnectTimeout + ", requestReadTimeout=" + this.requestReadTimeout + ", sendState=" - + this.sendState + ", headers=" + this.headers + ", sendAllLabels=" + this.sendAllLabels + "]"; + + this.sendState + ", headers=" + this.headers + ", sendAllLabels=" + this.sendAllLabels + ", oauth2" + + this.oauth2 + "]"; } /** @@ -526,4 +538,52 @@ public enum MultipleUriStrategy { } + public static class OAuth2Properties { + + /** + * Default client registration id. + */ + public static final String CLIENT_REGISTRATION_ID = "config-oauth2-client"; + + /** + * Flag to say that the remote configuration server is configured with OAuth2. + * Default false.; + */ + private boolean enabled = false; + + private OAuth2ClientProperties.Provider provider = new OAuth2ClientProperties.Provider(); + + private OAuth2ClientProperties.Registration registration = new OAuth2ClientProperties.Registration(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public OAuth2ClientProperties.Provider getProvider() { + return provider; + } + + public void setProvider(OAuth2ClientProperties.Provider provider) { + this.provider = provider; + } + + public OAuth2ClientProperties.Registration getRegistration() { + return registration; + } + + public void setRegistration(OAuth2ClientProperties.Registration registration) { + this.registration = registration; + } + + @Override + public String toString() { + return "OAuth2Properties [" + "enabled=" + enabled + "]"; + } + + } + } 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 abc6f02dcf..31a81161ff 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,10 @@ import java.io.IOException; import java.security.GeneralSecurityException; -import java.util.Arrays; +import java.util.ArrayList; import java.util.Base64; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -35,6 +36,8 @@ import org.apache.hc.core5.http.io.SocketConfig; import org.apache.hc.core5.util.Timeout; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientProperties; +import org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientPropertiesMapper; import org.springframework.cloud.configuration.SSLContextFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpRequest; @@ -44,6 +47,16 @@ import org.springframework.http.client.ClientHttpResponse; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; +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.web.client.RestTemplate; import static org.springframework.cloud.config.client.ConfigClientProperties.AUTHORIZATION; @@ -77,15 +90,67 @@ 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)); + } + + if (properties.getOauth2().isEnabled()) { + ClientHttpRequestInterceptor oauth2Interceptor = createOauth2Interceptor(properties.getOauth2()); + interceptors.add(oauth2Interceptor); } + template.setInterceptors(interceptors); return template; } + private ClientHttpRequestInterceptor createOauth2Interceptor(ConfigClientProperties.OAuth2Properties properties) { + final OAuth2AuthorizedClientManager authorizedClientManager = createAuthorizedClientManager(properties); + OAuth2ClientHttpRequestInterceptor oauth2Interceptor = new OAuth2ClientHttpRequestInterceptor( + authorizedClientManager); + oauth2Interceptor + .setClientRegistrationIdResolver(request -> ConfigClientProperties.OAuth2Properties.CLIENT_REGISTRATION_ID); + return oauth2Interceptor; + } + + private OAuth2AuthorizedClientManager createAuthorizedClientManager( + ConfigClientProperties.OAuth2Properties properties) { + + OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder() + .clientCredentials() + .refreshToken() + .build(); + + ClientRegistrationRepository clientRegistrationRepository = clientRegistrationRepository(properties); + + OAuth2AuthorizedClientService authorizedClientService = new InMemoryOAuth2AuthorizedClientService( + clientRegistrationRepository); + + AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager = new AuthorizedClientServiceOAuth2AuthorizedClientManager( + clientRegistrationRepository, authorizedClientService); + authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider); + return authorizedClientManager; + } + + private ClientRegistrationRepository clientRegistrationRepository( + ConfigClientProperties.OAuth2Properties properties) { + OAuth2ClientProperties oauth2ClientProperties = new OAuth2ClientProperties(); + properties.getRegistration().setProvider(null); // In case it was set in config + // properties + oauth2ClientProperties.getRegistration() + .put(ConfigClientProperties.OAuth2Properties.CLIENT_REGISTRATION_ID, properties.getRegistration()); + oauth2ClientProperties.getProvider() + .put(ConfigClientProperties.OAuth2Properties.CLIENT_REGISTRATION_ID, properties.getProvider()); + oauth2ClientProperties.afterPropertiesSet(); + + List registrations = new ArrayList<>( + new OAuth2ClientPropertiesMapper(oauth2ClientProperties).asClientRegistrations().values()); + return new InMemoryClientRegistrationRepository(registrations); + } + protected ClientHttpRequestFactory createHttpRequestFactory(ConfigClientProperties client) { if (client.getTls().isEnabled()) { try {