+ * 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+ * 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.+ * 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