Skip to content

Commit

Permalink
Uses OAuth2AuthorizedClientManager in oauth2 interceptors (#392)
Browse files Browse the repository at this point in the history
  • Loading branch information
kvmw authored Dec 21, 2023
1 parent 3cb0846 commit 524a158
Show file tree
Hide file tree
Showing 10 changed files with 382 additions and 248 deletions.
1 change: 1 addition & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ springCloudVersion=2023.0.0
springBootVersion=3.2.0
javaCfenvVersion=3.1.3
nohttpVersion=0.0.11
wireMockVersion=3.3.1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
*/
package io.pivotal.spring.cloud.config.client;

import java.util.HashMap;
import java.util.Map;

import org.slf4j.Logger;
Expand Down Expand Up @@ -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 {
Expand All @@ -69,34 +68,43 @@ 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<Map<String, Long>> 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")
public RestTemplate restTemplate() {
return new RestTemplate();
}

private HttpEntity<Map<String, Long>> buildTokenRenewRequest(String vaultToken, long renewTTL) {
Map<String, Long> 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<Map<String, Long>> 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);
}

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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), };
}

}
Loading

0 comments on commit 524a158

Please sign in to comment.