diff --git a/gradle.properties b/gradle.properties index 1df38f6..c90856a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,3 +5,4 @@ springCloudVersion=2023.0.0 springBootVersion=3.2.0 javaCfenvVersion=3.1.3 nohttpVersion=0.0.11 +wireMockVersion=3.3.1 \ No newline at end of file diff --git a/spring-cloud-services-config-client-autoconfigure/build.gradle b/spring-cloud-services-config-client-autoconfigure/build.gradle index c9ac7f5..c4e9332 100644 --- a/spring-cloud-services-config-client-autoconfigure/build.gradle +++ b/spring-cloud-services-config-client-autoconfigure/build.gradle @@ -28,6 +28,7 @@ dependencies { testImplementation('org.junit.vintage:junit-vintage-engine') testImplementation("org.springframework.cloud:spring-cloud-config-server") testImplementation("org.awaitility:awaitility:4.2.0") + testImplementation("org.wiremock:wiremock-standalone:${wireMockVersion}") } publishing { diff --git a/spring-cloud-services-config-client-autoconfigure/src/main/java/io/pivotal/spring/cloud/config/client/OAuth2AuthorizedClientHttpRequestInterceptor.java b/spring-cloud-services-config-client-autoconfigure/src/main/java/io/pivotal/spring/cloud/config/client/OAuth2AuthorizedClientHttpRequestInterceptor.java index 7bb0b71..9917368 100644 --- a/spring-cloud-services-config-client-autoconfigure/src/main/java/io/pivotal/spring/cloud/config/client/OAuth2AuthorizedClientHttpRequestInterceptor.java +++ b/spring-cloud-services-config-client-autoconfigure/src/main/java/io/pivotal/spring/cloud/config/client/OAuth2AuthorizedClientHttpRequestInterceptor.java @@ -16,19 +16,15 @@ package io.pivotal.spring.cloud.config.client; import java.io.IOException; -import java.time.Clock; -import java.time.Instant; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpRequest; import org.springframework.http.client.ClientHttpRequestExecution; import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.http.client.ClientHttpResponse; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; -import org.springframework.security.oauth2.client.endpoint.DefaultClientCredentialsTokenResponseClient; -import org.springframework.security.oauth2.client.endpoint.OAuth2ClientCredentialsGrantRequest; +import org.springframework.security.oauth2.client.*; import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; /** * {@link ClientHttpRequestInterceptor} implementation to add authorization header to @@ -38,26 +34,34 @@ */ public class OAuth2AuthorizedClientHttpRequestInterceptor implements ClientHttpRequestInterceptor { - final ClientRegistration clientRegistration; + private final AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedManager; - private OAuth2AccessToken accessToken; + private final OAuth2AuthorizeRequest authorizeRequest; - public OAuth2AuthorizedClientHttpRequestInterceptor(ClientRegistration clientRegistration) { - this.clientRegistration = clientRegistration; + public OAuth2AuthorizedClientHttpRequestInterceptor(ClientRegistration registration) { + + var repository = new InMemoryClientRegistrationRepository(registration); + var service = new InMemoryOAuth2AuthorizedClientService(repository); + + authorizedManager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(repository, service); + + var authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build(); + authorizedManager.setAuthorizedClientProvider(authorizedClientProvider); + + this.authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId(registration.getRegistrationId()) + .principal(registration.getRegistrationId()) + .build(); } @Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { - Instant now = Clock.systemUTC().instant(); - if (accessToken == null || now.isAfter(accessToken.getExpiresAt())) { - DefaultClientCredentialsTokenResponseClient tokenResponseClient = new DefaultClientCredentialsTokenResponseClient(); - OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest( - clientRegistration); - accessToken = tokenResponseClient.getTokenResponse(clientCredentialsGrantRequest).getAccessToken(); + + OAuth2AuthorizedClient authorize = this.authorizedManager.authorize(authorizeRequest); + if (authorize != null) { + request.getHeaders().add(HttpHeaders.AUTHORIZATION, "Bearer " + authorize.getAccessToken().getTokenValue()); } - request.getHeaders().add(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken.getTokenValue()); return execution.execute(request, body); } diff --git a/spring-cloud-services-config-client-autoconfigure/src/main/java/io/pivotal/spring/cloud/config/client/VaultTokenRenewalAutoConfiguration.java b/spring-cloud-services-config-client-autoconfigure/src/main/java/io/pivotal/spring/cloud/config/client/VaultTokenRenewalAutoConfiguration.java index 2f049bb..4ccc337 100644 --- a/spring-cloud-services-config-client-autoconfigure/src/main/java/io/pivotal/spring/cloud/config/client/VaultTokenRenewalAutoConfiguration.java +++ b/spring-cloud-services-config-client-autoconfigure/src/main/java/io/pivotal/spring/cloud/config/client/VaultTokenRenewalAutoConfiguration.java @@ -15,7 +15,6 @@ */ package io.pivotal.spring.cloud.config.client; -import java.util.HashMap; import java.util.Map; import org.slf4j.Logger; @@ -56,7 +55,7 @@ @AutoConfiguration(after = ConfigClientAutoConfiguration.class) @ConditionalOnBean(ConfigClientProperties.class) @ConditionalOnProperty(prefix = "spring.cloud.config", - name = { "token", "client.oauth2.clientId", "client.oauth2.clientSecret", "client.oauth2.accessTokenUri" }) + name = { "token", "client.oauth2.client-id", "client.oauth2.client-secret", "client.oauth2.access-token-uri" }) @EnableConfigurationProperties(ConfigClientOAuth2Properties.class) @EnableScheduling public class VaultTokenRenewalAutoConfiguration { @@ -69,21 +68,22 @@ public VaultTokenRefresher vaultTokenRefresher(ConfigClientProperties configClie @Qualifier("vaultTokenRenewal") RestTemplate restTemplate, @Value("${spring.cloud.config.token}") String vaultToken, // Default to a 300 second (5 minute) TTL - @Value("${vault.token.ttl:300000}") long renewTTL) { - ClientRegistration clientRegistration = ClientRegistration.withRegistrationId("config-client") + @Value("${vault.token.ttl:300000}") long renewTtl) { + + var clientRegistration = ClientRegistration.withRegistrationId("config-client") .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .clientId(configClientOAuth2Properties.getClientId()) .clientSecret(configClientOAuth2Properties.getClientSecret()) .scope(configClientOAuth2Properties.getScope()) .tokenUri(configClientOAuth2Properties.getAccessTokenUri()) .build(); + restTemplate.getInterceptors().add(new OAuth2AuthorizedClientHttpRequestInterceptor(clientRegistration)); - String obscuredToken = vaultToken.substring(0, 4) + "[*]" + vaultToken.substring(vaultToken.length() - 4); - String refreshUri = configClientProperties.getUri()[0] + "/vault/v1/auth/token/renew-self"; // convert to seconds, since that's what Vault wants - long renewTTLInMS = renewTTL / 1000; - HttpEntity> request = buildTokenRenewRequest(vaultToken, renewTTLInMS); - return new VaultTokenRefresher(restTemplate, obscuredToken, renewTTL, refreshUri, request); + var request = buildTokenRenewRequest(vaultToken, renewTtl / 1000); + + return new VaultTokenRefresher(restTemplate, obscuredToken(vaultToken), renewTtl, + refreshUri(configClientProperties), request); } @Bean("vaultTokenRenewal") @@ -91,12 +91,20 @@ public RestTemplate restTemplate() { return new RestTemplate(); } - private HttpEntity> buildTokenRenewRequest(String vaultToken, long renewTTL) { - Map requestBody = new HashMap<>(); - requestBody.put("increment", renewTTL); - HttpHeaders headers = new HttpHeaders(); + private String refreshUri(ConfigClientProperties configClientProperties) { + return configClientProperties.getUri()[0] + "/vault/v1/auth/token/renew-self"; + } + + private String obscuredToken(String vaultToken) { + return vaultToken.substring(0, 4) + "[*]" + vaultToken.substring(vaultToken.length() - 4); + } + + private HttpEntity> buildTokenRenewRequest(String vaultToken, long renewTtlInMs) { + var requestBody = Map.of("increment", renewTtlInMs); + var headers = new HttpHeaders(); headers.set("X-Vault-Token", vaultToken); headers.setContentType(MediaType.APPLICATION_JSON); + return new HttpEntity<>(requestBody, headers); } diff --git a/spring-cloud-services-config-client-autoconfigure/src/test/java/io/pivotal/spring/cloud/config/client/ConfigClientAutoConfigResourceTest.java b/spring-cloud-services-config-client-autoconfigure/src/test/java/io/pivotal/spring/cloud/config/client/ConfigClientAutoConfigResourceTest.java deleted file mode 100644 index 43b9e16..0000000 --- a/spring-cloud-services-config-client-autoconfigure/src/test/java/io/pivotal/spring/cloud/config/client/ConfigClientAutoConfigResourceTest.java +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2017 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 io.pivotal.spring.cloud.config.client; - -import org.junit.Test; - -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.test.context.runner.WebApplicationContextRunner; -import org.springframework.cloud.config.client.ConfigClientAutoConfiguration; -import org.springframework.cloud.config.client.ConfigClientProperties; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.client.RestTemplate; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.security.oauth2.core.AuthorizationGrantType.CLIENT_CREDENTIALS; - -public class ConfigClientAutoConfigResourceTest { - - private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() - .withAllowBeanDefinitionOverriding(true) - .withConfiguration(AutoConfigurations.of(ConfigResourceClientAutoConfiguration.class, - ConfigClientAutoConfiguration.class)); - - @Test - public void plainTextConfigClientIsNotCreated() { - contextRunner.run(context -> { - assertThat(context).hasSingleBean(ConfigClientProperties.class); - assertThat(context).doesNotHaveBean(PlainTextConfigClient.class); - }); - } - - @Test - public void plainTextConfigClientIsCreated() { - var pairs = oauth2Properties("::id::", "::secret::", "::uri::"); - - contextRunner.withPropertyValues(pairs).run(context -> { - assertThat(context).hasSingleBean(ConfigClientProperties.class); - assertThat(context).hasSingleBean(PlainTextConfigClient.class); - }); - } - - @Test - public void authorizationInterceptorIsConfigured() { - var pairs = oauth2Properties("::id::", "::secret::", "::uri::"); - - contextRunner.withPropertyValues(pairs).run(context -> { - assertThat(context).hasSingleBean(OAuth2ConfigResourceClient.class); - var config = context.getBean(OAuth2ConfigResourceClient.class); - - var clientRegistration = getAuthInterceptorConfiguration(config); - assertThat(clientRegistration.getClientId()).isEqualTo("::id::"); - assertThat(clientRegistration.getClientSecret()).isEqualTo("::secret::"); - assertThat(clientRegistration.getProviderDetails().getTokenUri()).isEqualTo("::uri::"); - assertThat(clientRegistration.getAuthorizationGrantType()).isEqualTo(CLIENT_CREDENTIALS); - assertThat(clientRegistration.getScopes()).isNull(); - }); - } - - @Test - public void optionalScopePropertyIsSupported() { - var pairs = oauth2Properties("::client id::", "::client secret::", "::token uri::"); - var scope = "spring.cloud.config.client.oauth2.scope=profile,email"; - contextRunner.withPropertyValues(pairs).withPropertyValues(scope).run(context -> { - assertThat(context).hasSingleBean(OAuth2ConfigResourceClient.class); - var config = context.getBean(OAuth2ConfigResourceClient.class); - - var clientRegistration = getAuthInterceptorConfiguration(config); - assertThat(clientRegistration.getClientId()).isEqualTo("::client id::"); - assertThat(clientRegistration.getClientSecret()).isEqualTo("::client secret::"); - assertThat(clientRegistration.getProviderDetails().getTokenUri()).isEqualTo("::token uri::"); - assertThat(clientRegistration.getAuthorizationGrantType()).isEqualTo(CLIENT_CREDENTIALS); - assertThat(clientRegistration.getScopes()).containsExactlyInAnyOrder("email", "profile"); - }); - } - - private ClientRegistration getAuthInterceptorConfiguration(OAuth2ConfigResourceClient config) { - var restTemplate = (RestTemplate) ReflectionTestUtils.getField(config, "restTemplate"); - assertThat(restTemplate).isNotNull(); - - var interceptors = restTemplate.getInterceptors(); - assertThat(interceptors).hasSize(1); - assertThat(interceptors.get(0)).isInstanceOf(OAuth2AuthorizedClientHttpRequestInterceptor.class); - var interceptor = (OAuth2AuthorizedClientHttpRequestInterceptor) interceptors.get(0); - return interceptor.clientRegistration; - } - - private String[] oauth2Properties(String clientId, String clientSecret, String tokenUri) { - return new String[] { String.format("spring.cloud.config.client.oauth2.client-id=%s", clientId), - String.format("spring.cloud.config.client.oauth2.client-secret=%s", clientSecret), - String.format("spring.cloud.config.client.oauth2.access-token-uri=%s", tokenUri) }; - } - -} diff --git a/spring-cloud-services-config-client-autoconfigure/src/test/java/io/pivotal/spring/cloud/config/client/ConfigResourceClientAutoConfigurationTest.java b/spring-cloud-services-config-client-autoconfigure/src/test/java/io/pivotal/spring/cloud/config/client/ConfigResourceClientAutoConfigurationTest.java new file mode 100644 index 0000000..f63c03b --- /dev/null +++ b/spring-cloud-services-config-client-autoconfigure/src/test/java/io/pivotal/spring/cloud/config/client/ConfigResourceClientAutoConfigurationTest.java @@ -0,0 +1,136 @@ +/* + * Copyright 2017 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 io.pivotal.spring.cloud.config.client; + +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.cloud.config.client.ConfigClientAutoConfiguration; + +import java.util.Base64; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static org.assertj.core.api.Assertions.assertThat; + +@WireMockTest(proxyMode = true) +public class ConfigResourceClientAutoConfigurationTest { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withAllowBeanDefinitionOverriding(true) + .withConfiguration(AutoConfigurations.of(ConfigResourceClientAutoConfiguration.class, + ConfigClientAutoConfiguration.class)); + + @Test + void configurationIsNotEnabledWhenOAuth2PropertiesAreMissing() { + contextRunner.run(context -> assertThat(context).doesNotHaveBean(ConfigResourceClient.class)); + } + + @Test + void configurationIsEnabledWhenOAuth2PropertiesArePresent() { + var pairs = applicationProperties("::id::", "::secret::"); + + contextRunner.withPropertyValues(pairs) + .run(context -> assertThat(context).hasSingleBean(ConfigResourceClient.class)); + } + + @Test + void authInterceptorIsConfiguredWhenOAuth2PropertiesArePresent() { + var pairs = applicationProperties("id", "secret"); + String base64Credentials = Base64.getEncoder().encodeToString(("id:secret").getBytes()); + + stubEndpoints(); + + contextRunner.withPropertyValues(pairs).run(context -> { + assertThat(context).hasSingleBean(ConfigResourceClient.class); + + tryFetchingAnyResource(context); + + verify(postRequestedFor(urlEqualTo("/token/uri")) + .withHeader("Content-Type", equalTo("application/x-www-form-urlencoded;charset=UTF-8")) + .withHeader("Authorization", equalTo("Basic " + base64Credentials)) + .withRequestBody(containing("grant_type=client_credentials"))); + + verify(getRequestedFor(urlEqualTo("/application/profile/label/path")).withHeader("Authorization", + equalTo("Bearer access-token"))); + }); + } + + @Test + void optionalScopePropertyShouldBeIncludedInTokenRequest() { + var pairs = applicationProperties("id", "secret", "profile,email"); + String base64Credentials = Base64.getEncoder().encodeToString(("id:secret").getBytes()); + + stubEndpoints(); + + contextRunner.withPropertyValues(pairs).run(context -> { + assertThat(context).hasSingleBean(ConfigResourceClient.class); + + tryFetchingAnyResource(context); + + verify(postRequestedFor(urlEqualTo("/token/uri")) + .withHeader("Content-Type", equalTo("application/x-www-form-urlencoded;charset=UTF-8")) + .withHeader("Authorization", equalTo("Basic " + base64Credentials)) + .withRequestBody(containing("grant_type=client_credentials")) + .withRequestBody(containing("scope=profile+email"))); + + verify(getRequestedFor(urlEqualTo("/application/profile/label/path")).withHeader("Authorization", + equalTo("Bearer access-token"))); + }); + } + + private void stubEndpoints() { + stubFor(post("/token/uri").withHost(equalTo("uaa.local")) + .willReturn(aResponse().withHeader("Content-Type", "application/json;charset=UTF-8").withBody(""" + { + "access_token" : "access-token", + "token_type" : "bearer", + "scope" : "emails.write" + }"""))); + + stubFor(get("/application/profile/label/path").withHost(equalTo("server.local")) + .willReturn(aResponse().withHeader("Content-Type", "plain/text").withBody("::text::"))); + } + + private void tryFetchingAnyResource(BeanFactory factory) { + factory.getBean(ConfigResourceClient.class).getPlainTextResource("profile", "label", "path"); + } + + private String[] applicationProperties(String clientId, String clientSecret) { + return applicationProperties(clientId, clientSecret, ""); + } + + private String[] applicationProperties(String clientId, String clientSecret, String scope) { + return new String[] { "spring.cloud.config.uri=http://server.local", + "spring.cloud.config.client.oauth2.access-token-uri=http://uaa.local/token/uri", + String.format("spring.cloud.config.client.oauth2.client-id=%s", clientId), + String.format("spring.cloud.config.client.oauth2.client-secret=%s", clientSecret), + String.format("spring.cloud.config.client.oauth2.scope=%s", scope), }; + } + +} diff --git a/spring-cloud-services-config-client-autoconfigure/src/test/java/io/pivotal/spring/cloud/config/client/VaultTokenRenewalAutoConfigurationTest.java b/spring-cloud-services-config-client-autoconfigure/src/test/java/io/pivotal/spring/cloud/config/client/VaultTokenRenewalAutoConfigurationTest.java index cb55182..66f93ed 100644 --- a/spring-cloud-services-config-client-autoconfigure/src/test/java/io/pivotal/spring/cloud/config/client/VaultTokenRenewalAutoConfigurationTest.java +++ b/spring-cloud-services-config-client-autoconfigure/src/test/java/io/pivotal/spring/cloud/config/client/VaultTokenRenewalAutoConfigurationTest.java @@ -16,87 +16,140 @@ package io.pivotal.spring.cloud.config.client; -import java.util.ArrayList; +import java.util.Base64; import java.util.concurrent.TimeUnit; -import org.junit.Test; -import org.mockito.Mockito; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.BeanFactory; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; import org.springframework.cloud.config.client.ConfigClientAutoConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; -import org.springframework.http.HttpEntity; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.core.AuthorizationGrantType; -import org.springframework.web.client.RestTemplate; +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.moreThan; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static io.pivotal.spring.cloud.config.client.VaultTokenRenewalAutoConfiguration.VaultTokenRefresher; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.atLeast; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; /** * @author Roy Clarkson * @author Dylan Roberts */ +@WireMockTest(proxyMode = true) public class VaultTokenRenewalAutoConfigurationTest { - private static final String CLIENT_ID = "clientId"; + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner().withConfiguration( + AutoConfigurations.of(ConfigClientAutoConfiguration.class, VaultTokenRenewalAutoConfiguration.class)); - private static final String CLIENT_SECRET = "clientSecret"; + @Test + void configurationIsNotEnabledWhenOAuth2PropertiesAreMissing() { + contextRunner.run(context -> assertThat(context).doesNotHaveBean(VaultTokenRefresher.class)); + } + + @Test + void configurationIsEnabledWhenOAuth2PropertiesArePresent() { + var pairs = applicationProperties("::id::", "::secret::"); + + contextRunner.withPropertyValues(pairs) + .run(context -> assertThat(context).hasSingleBean(VaultTokenRefresher.class)); + } + + @Test + void authInterceptorIsConfiguredWhenOAuth2PropertiesArePresent() { + var pairs = applicationProperties("id", "secret"); + String base64Credentials = Base64.getEncoder().encodeToString(("id:secret").getBytes()); + + stubTokenEndpoints(); + + contextRunner.withPropertyValues(pairs).run(context -> { + assertThat(context).hasSingleBean(VaultTokenRefresher.class); + + tryRefreshingToken(context); + + verify(postRequestedFor(urlEqualTo("/token/uri")) + .withHeader("Content-Type", equalTo("application/x-www-form-urlencoded;charset=UTF-8")) + .withHeader("Authorization", equalTo("Basic " + base64Credentials)) + .withRequestBody(containing("grant_type=client_credentials"))); + }); + } - private static final String TOKEN_URI = "tokenUri"; + @Test + void optionalScopePropertyShouldBeIncludedInTokenRequest() { + var pairs = applicationProperties("id", "secret", "profile,email"); + String base64Credentials = Base64.getEncoder().encodeToString(("id:secret").getBytes()); + + stubTokenEndpoints(); - private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(TestConfiguration.class, ConfigClientAutoConfiguration.class, - VaultTokenRenewalAutoConfiguration.class)); + contextRunner.withPropertyValues(pairs).run(context -> { + assertThat(context).hasSingleBean(VaultTokenRefresher.class); + + tryRefreshingToken(context); + + verify(postRequestedFor(urlEqualTo("/token/uri")) + .withHeader("Content-Type", equalTo("application/x-www-form-urlencoded;charset=UTF-8")) + .withHeader("Authorization", equalTo("Basic " + base64Credentials)) + .withRequestBody(containing("grant_type=client_credentials")) + .withRequestBody(containing("scope=profile+email"))); + }); + } @Test - public void scheduledVaultTokenRefresh() { - contextRunner - .withPropertyValues("spring.cloud.config.token=footoken", "vault.token.renew.rate=1000", - "spring.cloud.config.client.oauth2.clientId=" + CLIENT_ID, - "spring.cloud.config.client.oauth2.clientSecret=" + CLIENT_SECRET, - "spring.cloud.config.client.oauth2.accessTokenUri=" + TOKEN_URI) - .run(context -> { - RestTemplate restTemplate = context.getBean("mockRestTemplate", RestTemplate.class); - await().atMost(5L, TimeUnit.SECONDS).untilAsserted(() -> { - verify(restTemplate, atLeast(4)).postForObject(anyString(), any(HttpEntity.class), any()); - assertThat(restTemplate.getInterceptors()).hasSize(1); - assertThat(restTemplate.getInterceptors().get(0)) - .isInstanceOf(OAuth2AuthorizedClientHttpRequestInterceptor.class); - OAuth2AuthorizedClientHttpRequestInterceptor interceptor = (OAuth2AuthorizedClientHttpRequestInterceptor) restTemplate - .getInterceptors() - .get(0); - ClientRegistration clientRegistration = interceptor.clientRegistration; - assertThat(clientRegistration.getClientId()).isEqualTo(CLIENT_ID); - assertThat(clientRegistration.getClientSecret()).isEqualTo(CLIENT_SECRET); - assertThat(clientRegistration.getProviderDetails().getTokenUri()).isEqualTo(TOKEN_URI); - assertThat(clientRegistration.getAuthorizationGrantType()) - .isEqualTo(AuthorizationGrantType.CLIENT_CREDENTIALS); - }); + void schedulesVaultTokenRefresh() { + var pairs = applicationProperties("id", "secret", "profile,email"); + + stubTokenEndpoints(); + + contextRunner.withPropertyValues(pairs).run(context -> { + assertThat(context).hasSingleBean(VaultTokenRefresher.class); + + await().atMost(3L, TimeUnit.SECONDS).untilAsserted(() -> { + verify(moreThan(2), + postRequestedFor(urlEqualTo("/vault/v1/auth/token/renew-self")) + .withHeader("Content-Type", equalTo("application/json")) + .withHeader("Authorization", equalTo("Bearer access-token")) + .withHeader("X-Vault-Token", equalTo("vault-token")) + .withRequestBody(containing("{\"increment\":300}"))); }); + }); + } + + private void stubTokenEndpoints() { + stubFor(post("/token/uri").withHost(equalTo("uaa.local")) + .willReturn(aResponse().withHeader("Content-Type", "application/json;charset=UTF-8").withBody(""" + { + "access_token" : "access-token", + "token_type" : "bearer", + "scope" : "emails.write" + }"""))); + + stubFor(post("/vault/v1/auth/token/renew-self").withHost(equalTo("server.local")) + .willReturn(aResponse().withHeader("Content-Type", "plain/text").withBody("new-token"))); } - @Configuration - public static class TestConfiguration { + private void tryRefreshingToken(BeanFactory factory) { + factory.getBean(VaultTokenRefresher.class).refreshVaultToken(); + } - @Qualifier("vaultTokenRenewal") - @Primary - @Bean - public RestTemplate mockRestTemplate() { - RestTemplate mock = Mockito.mock(RestTemplate.class); - when(mock.getInterceptors()).thenReturn(new ArrayList<>()); - return mock; - } + private String[] applicationProperties(String clientId, String clientSecret) { + return applicationProperties(clientId, clientSecret, ""); + } + private String[] applicationProperties(String clientId, String clientSecret, String scope) { + return new String[] { "vault.token.renew.rate=1000", "spring.cloud.config.token=vault-token", + "spring.cloud.config.uri=http://server.local", + "spring.cloud.config.client.oauth2.access-token-uri=http://uaa.local/token/uri", + String.format("spring.cloud.config.client.oauth2.client-id=%s", clientId), + String.format("spring.cloud.config.client.oauth2.client-secret=%s", clientSecret), + String.format("spring.cloud.config.client.oauth2.scope=%s", scope), }; } } diff --git a/spring-cloud-services-service-registry-autoconfigure/build.gradle b/spring-cloud-services-service-registry-autoconfigure/build.gradle index a63e48f..389a610 100644 --- a/spring-cloud-services-service-registry-autoconfigure/build.gradle +++ b/spring-cloud-services-service-registry-autoconfigure/build.gradle @@ -26,6 +26,7 @@ dependencies { testImplementation('org.junit.jupiter:junit-jupiter-api') testImplementation('org.junit.vintage:junit-vintage-engine') testImplementation("org.springframework.boot:spring-boot-starter-web") + testImplementation("org.wiremock:wiremock-standalone:${wireMockVersion}") } publishing { diff --git a/spring-cloud-services-service-registry-autoconfigure/src/main/java/io/pivotal/spring/cloud/service/registry/OAuth2AuthorizedClientHttpRequestInterceptor.java b/spring-cloud-services-service-registry-autoconfigure/src/main/java/io/pivotal/spring/cloud/service/registry/OAuth2AuthorizedClientHttpRequestInterceptor.java index 1071b7a..e31bec0 100644 --- a/spring-cloud-services-service-registry-autoconfigure/src/main/java/io/pivotal/spring/cloud/service/registry/OAuth2AuthorizedClientHttpRequestInterceptor.java +++ b/spring-cloud-services-service-registry-autoconfigure/src/main/java/io/pivotal/spring/cloud/service/registry/OAuth2AuthorizedClientHttpRequestInterceptor.java @@ -16,19 +16,15 @@ package io.pivotal.spring.cloud.service.registry; import java.io.IOException; -import java.time.Clock; -import java.time.Instant; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpRequest; import org.springframework.http.client.ClientHttpRequestExecution; import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.http.client.ClientHttpResponse; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; -import org.springframework.security.oauth2.client.endpoint.DefaultClientCredentialsTokenResponseClient; -import org.springframework.security.oauth2.client.endpoint.OAuth2ClientCredentialsGrantRequest; +import org.springframework.security.oauth2.client.*; import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; /** * {@link ClientHttpRequestInterceptor} implementation to add authorization header to @@ -38,26 +34,34 @@ */ public class OAuth2AuthorizedClientHttpRequestInterceptor implements ClientHttpRequestInterceptor { - final ClientRegistration clientRegistration; + private final AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedManager; - private OAuth2AccessToken accessToken; + private final OAuth2AuthorizeRequest authorizeRequest; - public OAuth2AuthorizedClientHttpRequestInterceptor(ClientRegistration clientRegistration) { - this.clientRegistration = clientRegistration; + public OAuth2AuthorizedClientHttpRequestInterceptor(ClientRegistration registration) { + + var repository = new InMemoryClientRegistrationRepository(registration); + var service = new InMemoryOAuth2AuthorizedClientService(repository); + + this.authorizedManager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(repository, service); + + var authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build(); + authorizedManager.setAuthorizedClientProvider(authorizedClientProvider); + + this.authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId(registration.getRegistrationId()) + .principal(registration.getRegistrationId()) + .build(); } @Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { - Instant now = Clock.systemUTC().instant(); - if (accessToken == null || now.isAfter(accessToken.getExpiresAt())) { - DefaultClientCredentialsTokenResponseClient tokenResponseClient = new DefaultClientCredentialsTokenResponseClient(); - OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest( - clientRegistration); - accessToken = tokenResponseClient.getTokenResponse(clientCredentialsGrantRequest).getAccessToken(); + + OAuth2AuthorizedClient authorize = this.authorizedManager.authorize(this.authorizeRequest); + if (authorize != null) { + request.getHeaders().add(HttpHeaders.AUTHORIZATION, "Bearer " + authorize.getAccessToken().getTokenValue()); } - request.getHeaders().add(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken.getTokenValue()); return execution.execute(request, body); } diff --git a/spring-cloud-services-service-registry-autoconfigure/src/test/java/io/pivotal/spring/cloud/service/registry/EurekaClientOAuth2AutoConfigurationTest.java b/spring-cloud-services-service-registry-autoconfigure/src/test/java/io/pivotal/spring/cloud/service/registry/EurekaClientOAuth2AutoConfigurationTest.java index 76cbf27..09ef6fb 100644 --- a/spring-cloud-services-service-registry-autoconfigure/src/test/java/io/pivotal/spring/cloud/service/registry/EurekaClientOAuth2AutoConfigurationTest.java +++ b/spring-cloud-services-service-registry-autoconfigure/src/test/java/io/pivotal/spring/cloud/service/registry/EurekaClientOAuth2AutoConfigurationTest.java @@ -15,88 +15,122 @@ */ package io.pivotal.spring.cloud.service.registry; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.BeanFactory; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.test.util.ReflectionTestUtils; - +import org.springframework.http.HttpMethod; + +import java.io.IOException; +import java.net.URI; +import java.util.Base64; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.security.oauth2.core.AuthorizationGrantType.CLIENT_CREDENTIALS; /** * @author Dylan Roberts */ +@WireMockTest(proxyMode = true) public class EurekaClientOAuth2AutoConfigurationTest { private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() .withConfiguration(AutoConfigurations.of(EurekaClientOAuth2AutoConfiguration.class)); @Test - public void oauth2RequestFactorySupplierIsNotCreated() { + void configurationIsNotEnabledWhenOAuth2PropertiesAreMissing() { contextRunner .run(context -> assertThat(context).doesNotHaveBean(EurekaClientOAuth2HttpRequestFactorySupplier.class)); } @Test - public void oauth2RequestFactorySupplierIsCreated() { - var pairs = oauth2Properties("::id::", "::secret::", "::uri::"); + void configurationIsEnabledWhenOAuth2PropertiesArePresent() { + var pairs = applicationProperties("::id::", "::secret::"); contextRunner.withPropertyValues(pairs) .run(context -> assertThat(context).hasSingleBean(EurekaClientOAuth2HttpRequestFactorySupplier.class)); } @Test - public void authorizationInterceptorIsConfigured() { - var pairs = oauth2Properties("::id::", "::secret::", "::uri::"); + void authInterceptorIsConfiguredWhenOAuth2PropertiesArePresent() { + var pairs = applicationProperties("id", "secret"); + String base64Credentials = Base64.getEncoder().encodeToString(("id:secret").getBytes()); + + stubTokenEndpoint(); contextRunner.withPropertyValues(pairs).run(context -> { assertThat(context).hasSingleBean(EurekaClientOAuth2HttpRequestFactorySupplier.class); - var supplier = context.getBean(EurekaClientOAuth2HttpRequestFactorySupplier.class); - var clientRegistration = getAuthInterceptorConfiguration(supplier); + callAnyEndpoint(context); - assertThat(clientRegistration.getClientId()).isEqualTo("::id::"); - assertThat(clientRegistration.getClientSecret()).isEqualTo("::secret::"); - assertThat(clientRegistration.getProviderDetails().getTokenUri()).isEqualTo("::uri::"); - assertThat(clientRegistration.getAuthorizationGrantType()).isEqualTo(CLIENT_CREDENTIALS); - assertThat(clientRegistration.getScopes()).isNull(); + verify(postRequestedFor(urlEqualTo("/token/uri")) + .withHeader("Content-Type", equalTo("application/x-www-form-urlencoded;charset=UTF-8")) + .withHeader("Authorization", equalTo("Basic " + base64Credentials)) + .withRequestBody(containing("grant_type=client_credentials"))); }); } @Test - public void optionalScopePropertyIsSupported() { - var pairs = oauth2Properties("::client id::", "::client secret::", "::token uri::"); - var scope = "eureka.client.oauth2.scope=profile,email"; - contextRunner.withPropertyValues(pairs).withPropertyValues(scope).run(context -> { + void optionalScopePropertyShouldBeIncludedInTokenRequest() { + var pairs = applicationProperties("id", "secret", "profile,email"); + String base64Credentials = Base64.getEncoder().encodeToString(("id:secret").getBytes()); + + stubTokenEndpoint(); + + contextRunner.withPropertyValues(pairs).run(context -> { assertThat(context).hasSingleBean(EurekaClientOAuth2HttpRequestFactorySupplier.class); - var supplier = context.getBean(EurekaClientOAuth2HttpRequestFactorySupplier.class); - var clientRegistration = getAuthInterceptorConfiguration(supplier); + callAnyEndpoint(context); - assertThat(clientRegistration.getClientId()).isEqualTo("::client id::"); - assertThat(clientRegistration.getClientSecret()).isEqualTo("::client secret::"); - assertThat(clientRegistration.getProviderDetails().getTokenUri()).isEqualTo("::token uri::"); - assertThat(clientRegistration.getAuthorizationGrantType()).isEqualTo(CLIENT_CREDENTIALS); - assertThat(clientRegistration.getScopes()).containsExactlyInAnyOrder("email", "profile"); + verify(postRequestedFor(urlEqualTo("/token/uri")) + .withHeader("Content-Type", equalTo("application/x-www-form-urlencoded;charset=UTF-8")) + .withHeader("Authorization", equalTo("Basic " + base64Credentials)) + .withRequestBody(containing("grant_type=client_credentials")) + .withRequestBody(containing("scope=profile+email"))); }); } - private ClientRegistration getAuthInterceptorConfiguration(EurekaClientOAuth2HttpRequestFactorySupplier supplier) { - var interceptor = (OAuth2AuthorizedClientHttpRequestInterceptor) ReflectionTestUtils.getField(supplier, - "oAuth2AuthorizedClientHttpRequestInterceptor"); - assertThat(interceptor).isNotNull(); + private void stubTokenEndpoint() { + stubFor(post("/token/uri").withHost(equalTo("uaa.local")) + .willReturn(aResponse().withHeader("Content-Type", "application/json;charset=UTF-8").withBody(""" + { + "access_token" : "access-token", + "token_type" : "bearer", + "scope" : "emails.write" + }"""))); + } + + private void callAnyEndpoint(BeanFactory factory) { + var supplier = factory.getBean(EurekaClientOAuth2HttpRequestFactorySupplier.class); + + try { + supplier.get(null, null) + .createRequest(URI.create("http://server.local/ping"), HttpMethod.GET) + .execute() + .close(); + } + catch (IOException e) { + // Ignore exceptions in the GET call. + } + } - ClientRegistration clientRegistration = (ClientRegistration) ReflectionTestUtils.getField(interceptor, - "clientRegistration"); - assertThat(clientRegistration).isNotNull(); - return clientRegistration; + private String[] applicationProperties(String clientId, String clientSecret) { + return applicationProperties(clientId, clientSecret, ""); } - private String[] oauth2Properties(String clientId, String clientSecret, String tokenUri) { - return new String[] { String.format("eureka.client.oauth2.client-id=%s", clientId), + private String[] applicationProperties(String clientId, String clientSecret, String scope) { + return new String[] { "eureka.client.oauth2.access-token-uri=http://uaa.local/token/uri", + String.format("eureka.client.oauth2.client-id=%s", clientId), String.format("eureka.client.oauth2.client-secret=%s", clientSecret), - String.format("eureka.client.oauth2.access-token-uri=%s", tokenUri) }; + String.format("eureka.client.oauth2.scope=%s", scope), }; } } \ No newline at end of file