diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 24ca536e13..c113bbc609 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -19,6 +19,8 @@ *** xref:features/exploits/headers.adoc[HTTP Headers] *** xref:features/exploits/http.adoc[HTTP Requests] ** xref:features/integrations/index.adoc[Integrations] +*** REST Client +**** xref:features/integrations/rest/http-interface.adoc[HTTP Interface Integration] *** xref:features/integrations/cryptography.adoc[Cryptography] *** xref:features/integrations/data.adoc[Spring Data] *** xref:features/integrations/concurrency.adoc[Java's Concurrency APIs] diff --git a/docs/modules/ROOT/pages/features/integrations/rest/http-interface.adoc b/docs/modules/ROOT/pages/features/integrations/rest/http-interface.adoc new file mode 100644 index 0000000000..535ae27bdb --- /dev/null +++ b/docs/modules/ROOT/pages/features/integrations/rest/http-interface.adoc @@ -0,0 +1,66 @@ += HTTP Interface Integration + +Spring Security's OAuth Support can integrate with `RestClient` and `WebClient` {spring-framework-reference-url}/integration/rest-clients.html[HTTP Interface based REST Clients]. + + +[[configuration]] +== Configuration +After xref:features/integrations/rest/http-interface.adoc#configuration-restclient[RestClient] or xref:features/integrations/rest/http-interface.adoc#configuration-webclient[WebClient] specific configuration, usage of xref:features/integrations/rest/http-interface.adoc[] only requires adding a xref:features/integrations/rest/http-interface.adoc#client-registration-id[`@ClientRegistrationId`] to methods that require OAuth. + +Since the presense of xref:features/integrations/rest/http-interface.adoc#client-registration-id[`@ClientRegistrationId`] determines if and how the OAuth token will be resolved, it is safe to add Spring Security's OAuth support any configuration. + +[[configuration-restclient]] +=== RestClient Configuration + +Spring Security's OAuth Support can integrate with {spring-framework-reference-url}/integration/rest-clients.html[HTTP Interface based REST Clients] backed by RestClient. +The first step is to xref:servlet/oauth2/client/core.adoc#oauth2Client-authorized-manager-provider[create an `OAuthAuthorizedClientManager` Bean]. + +Next you must configure `HttpServiceProxyFactory` and `RestClient` to be aware of xref:./http-interface.adoc#client-registration-id[@ClientRegistrationId] +To simplify this configuration, use javadoc:org.springframework.security.oauth2.client.web.client.support.OAuth2RestClientHttpServiceGroupConfigurer[]. + +include-code::./RestClientHttpInterfaceIntegrationConfiguration[tag=config,indent=0] + +The configuration: + +- Adds xref:features/integrations/rest/http-interface.adoc#client-registration-id-processor[`ClientRegistrationIdProcessor`] to {spring-framework-reference-url}/integration/rest-clients.html#rest-http-interface[`HttpServiceProxyFactory`] +- Adds xref:servlet/oauth2/client/authorized-clients.adoc#oauth2-client-rest-client[`OAuth2ClientHttpRequestInterceptor`] to the `RestClient` + +[[configuration-webclient]] +=== WebClient Configuration + +Spring Security's OAuth Support can integrate with {spring-framework-reference-url}/integration/rest-clients.html[HTTP Interface based REST Clients] backed by `WebClient`. +The first step is to xref:reactive/oauth2/client/core.adoc#oauth2Client-authorized-manager-provider[create an `ReactiveOAuthAuthorizedClientManager` Bean]. + +Next you must configure `HttpServiceProxyFactory` and `WebRestClient` to be aware of xref:./http-interface.adoc#client-registration-id[@ClientRegistrationId] +To simplify this configuration, use javadoc:org.springframework.security.oauth2.client.web.reactive.function.client.support.OAuth2WebClientHttpServiceGroupConfigurer[]. + +include-code::./ServerWebClientHttpInterfaceIntegrationConfiguration[tag=config,indent=0] + +The configuration: + +- Adds xref:features/integrations/rest/http-interface.adoc#client-registration-id-processor[`ClientRegistrationIdProcessor`] to {spring-framework-reference-url}/integration/rest-clients.html#rest-http-interface[`HttpServiceProxyFactory`] +- Adds xref:reactive/oauth2/client/authorized-clients.adoc#oauth2-client-web-client[`ServerOAuth2AuthorizedClientExchangeFilterFunction`] to the `WebClient` + + +[[client-registration-id]] +== @ClientRegistrationId + +You can add the javadoc:org.springframework.security.oauth2.client.annotation.ClientRegistrationId[] on the HTTP Interface to specify which javadoc:org.springframework.security.oauth2.client.registration.ClientRegistration[] to use. + +include-code::./UserService[tag=getAuthenticatedUser] + +The xref:features/integrations/rest/http-interface.adoc#client-registration-id[`@ClientRegistrationId`] will be processed by xref:features/integrations/rest/http-interface.adoc#client-registration-id-processor[`ClientRegistrationIdProcessor`] + +[[client-registration-id-processor]] +== `ClientRegistrationIdProcessor` + +The xref:features/integrations/rest/http-interface.adoc#configuration[configured] javadoc:org.springframework.security.oauth2.client.web.client.ClientRegistrationIdProcessor[] will: + +- Automatically invoke javadoc:org.springframework.security.oauth2.client.web.ClientAttributes#clientRegistrationId(java.lang.String)[] for each xref:features/integrations/rest/http-interface.adoc#client-registration-id[`@ClientRegistrationId`]. +- This adds the javadoc:org.springframework.security.oauth2.client.registration.ClientRegistration#getId()[] to the attributes + +The `id` is then processed by: + +- `OAuth2ClientHttpRequestInterceptor` for xref:servlet/oauth2/client/authorized-clients.adoc#oauth2-client-rest-client[RestClient Integration] +- xref:servlet/oauth2/client/authorized-clients.adoc#oauth2-client-web-client[`ServletOAuth2AuthorizedClientExchangeFilterFunction`] (servlets) or xref:servlet/oauth2/client/authorized-clients.adoc#oauth2-client-web-client[`ServerOAuth2AuthorizedClientExchangeFilterFunction`] (reactive environments) for `WebClient`. + diff --git a/docs/modules/ROOT/pages/servlet/oauth2/client/authorized-clients.adoc b/docs/modules/ROOT/pages/servlet/oauth2/client/authorized-clients.adoc index 8864e4e7db..aa721f1413 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/client/authorized-clients.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/client/authorized-clients.adoc @@ -495,6 +495,11 @@ class RestClientConfig { ---- ===== +[[oauth2-client-rest-client-interface]] +=== HTTP Interface Integration + +Spring Security's OAuth support integrates with xref:features/integrations/rest/http-interface.adoc[]. + [[oauth2-client-web-client]] == [[oauth2Client-webclient-servlet]]WebClient Integration for Servlet Environments diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index 79f7ba57ff..140efdf0d4 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -7,3 +7,4 @@ Below are the highlights of the release, or you can view https://github.com/spri == Web * Added javadoc:org.springframework.security.web.authentication.preauth.x509.SubjectX500PrincipalExtractor[] +* Added OAuth2 Support for xref:features/integrations/rest/http-interface.adoc[HTTP Interface Integration] diff --git a/docs/spring-security-docs.gradle b/docs/spring-security-docs.gradle index ec459fc115..680ab725e9 100644 --- a/docs/spring-security-docs.gradle +++ b/docs/spring-security-docs.gradle @@ -39,6 +39,8 @@ dependencies { testImplementation project(':spring-security-config') testImplementation project(path : ':spring-security-config', configuration : 'tests') testImplementation project(':spring-security-test') + testImplementation project(':spring-security-oauth2-client') + testImplementation 'com.squareup.okhttp3:mockwebserver' testImplementation 'com.unboundid:unboundid-ldapsdk' testImplementation libs.webauthn4j.core testImplementation 'org.jetbrains.kotlin:kotlin-reflect' @@ -49,6 +51,7 @@ dependencies { testImplementation 'org.springframework:spring-webmvc' testImplementation 'jakarta.servlet:jakarta.servlet-api' + testImplementation 'io.mockk:mockk' testImplementation "org.junit.jupiter:junit-jupiter-api" testImplementation "org.junit.jupiter:junit-jupiter-params" testImplementation "org.junit.jupiter:junit-jupiter-engine" diff --git a/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/clientregistrationid/User.java b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/clientregistrationid/User.java new file mode 100644 index 0000000000..08efa3bfef --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/clientregistrationid/User.java @@ -0,0 +1,28 @@ +/* + * Copyright 2002-2025 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 clients 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.security.docs.features.integrations.rest.clientregistrationid; + +/** + * A user. + * @param login + * @param id + * @param name + * @author Rob Winch + * @see UserService + */ +public record User(String login, int id, String name) { +} diff --git a/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/clientregistrationid/UserService.java b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/clientregistrationid/UserService.java new file mode 100644 index 0000000000..f0088f887b --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/clientregistrationid/UserService.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2025 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 clients 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.security.docs.features.integrations.rest.clientregistrationid; + +import org.springframework.security.oauth2.client.annotation.ClientRegistrationId; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; + +/** + * Demonstrates a service for {@link ClientRegistrationId} and HTTP Interface clients. + * @author Rob Winch + */ +@HttpExchange +public interface UserService { + + // tag::getAuthenticatedUser[] + @GetExchange("/user") + @ClientRegistrationId("github") + User getAuthenticatedUser(); + // end::getAuthenticatedUser[] + +} diff --git a/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfiguration.java b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfiguration.java new file mode 100644 index 0000000000..73d8b37ff0 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfiguration.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-2025 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 clients 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.security.docs.features.integrations.rest.configurationrestclient; + +import okhttp3.mockwebserver.MockWebServer; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.docs.features.integrations.rest.clientregistrationid.UserService; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.client.support.OAuth2RestClientHttpServiceGroupConfigurer; +import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer; +import org.springframework.web.service.registry.ImportHttpServices; + +import static org.mockito.Mockito.mock; + +/** + * Documentation for {@link OAuth2RestClientHttpServiceGroupConfigurer}. + * @author Rob Winch + */ +@Configuration(proxyBeanMethods = false) +@ImportHttpServices(types = UserService.class) +public class RestClientHttpInterfaceIntegrationConfiguration { + + // tag::config[] + @Bean + OAuth2RestClientHttpServiceGroupConfigurer securityConfigurer( + OAuth2AuthorizedClientManager manager) { + return OAuth2RestClientHttpServiceGroupConfigurer.from(manager); + } + // end::config[] + + @Bean + OAuth2AuthorizedClientManager authorizedClientManager() { + return mock(OAuth2AuthorizedClientManager.class); + } + + @Bean + RestClientHttpServiceGroupConfigurer groupConfigurer(MockWebServer server) { + return groups -> { + + groups + .forEachClient((group, builder) -> builder + .baseUrl(server.url("").toString()) + .defaultHeader("Accept", "application/vnd.github.v3+json")); + }; + } + + @Bean + MockWebServer mockServer() { + return new MockWebServer(); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfigurationTests.java b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfigurationTests.java new file mode 100644 index 0000000000..5823aa90da --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfigurationTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2025 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 clients 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.security.docs.features.integrations.rest.configurationrestclient; + +import java.time.Duration; +import java.time.Instant; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.config.oauth2.client.CommonOAuth2Provider; +import org.springframework.security.docs.features.integrations.rest.clientregistrationid.UserService; +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.OAuth2AccessToken; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +/** + * Tests RestClient configuration for HTTP Interface clients. + * @author Rob Winch + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = RestClientHttpInterfaceIntegrationConfiguration.class) +class RestClientHttpInterfaceIntegrationConfigurationTests { + + @Test + void getAuthenticatedUser(@Autowired MockWebServer webServer, @Autowired OAuth2AuthorizedClientManager authorizedClients, @Autowired UserService users) + throws InterruptedException { + ClientRegistration registration = CommonOAuth2Provider.GITHUB.getBuilder("github").clientId("github").build(); + + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(Duration.ofMinutes(5)); + OAuth2AccessToken token = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "1234", + issuedAt, expiresAt); + OAuth2AuthorizedClient result = new OAuth2AuthorizedClient(registration, "rob", token); + given(authorizedClients.authorize(any())).willReturn(result); + + webServer.enqueue(new MockResponse().addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).setBody( + """ + {"login": "rob_winch", "id": 1234, "name": "Rob Winch" } + """)); + + users.getAuthenticatedUser(); + + assertThat(webServer.takeRequest().getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("Bearer " + token.getTokenValue()); + } + +} diff --git a/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationwebclient/ServerRestClientHttpInterfaceIntegrationConfigurationTests.java b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationwebclient/ServerRestClientHttpInterfaceIntegrationConfigurationTests.java new file mode 100644 index 0000000000..bdecbdc9b9 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationwebclient/ServerRestClientHttpInterfaceIntegrationConfigurationTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2025 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 clients 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.security.docs.features.integrations.rest.configurationwebclient; + +import java.time.Duration; +import java.time.Instant; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.config.oauth2.client.CommonOAuth2Provider; +import org.springframework.security.docs.features.integrations.rest.clientregistrationid.UserService; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +/** + * Demonstrates configuring RestClient with interface based proxy clients. + * @author Rob Winch + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = ServerWebClientHttpInterfaceIntegrationConfiguration.class) +class ServerRestClientHttpInterfaceIntegrationConfigurationTests { + + @Test + void getAuthenticatedUser(@Autowired MockWebServer webServer, @Autowired ReactiveOAuth2AuthorizedClientManager authorizedClients, @Autowired UserService users) + throws InterruptedException { + ClientRegistration registration = CommonOAuth2Provider.GITHUB.getBuilder("github").clientId("github").build(); + + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(Duration.ofMinutes(5)); + OAuth2AccessToken token = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "1234", + issuedAt, expiresAt); + OAuth2AuthorizedClient result = new OAuth2AuthorizedClient(registration, "rob", token); + given(authorizedClients.authorize(any())).willReturn(Mono.just(result)); + + webServer.enqueue(new MockResponse().addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).setBody( + """ + {"login": "rob_winch", "id": 1234, "name": "Rob Winch" } + """)); + + users.getAuthenticatedUser(); + + assertThat(webServer.takeRequest().getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("Bearer " + token.getTokenValue()); + } + +} diff --git a/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationwebclient/ServerWebClientHttpInterfaceIntegrationConfiguration.java b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationwebclient/ServerWebClientHttpInterfaceIntegrationConfiguration.java new file mode 100644 index 0000000000..06e1970b5a --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationwebclient/ServerWebClientHttpInterfaceIntegrationConfiguration.java @@ -0,0 +1,69 @@ +/* + * Copyright 2002-2025 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 clients 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.security.docs.features.integrations.rest.configurationwebclient; + +import okhttp3.mockwebserver.MockWebServer; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.docs.features.integrations.rest.clientregistrationid.UserService; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.client.support.OAuth2RestClientHttpServiceGroupConfigurer; +import org.springframework.security.oauth2.client.web.reactive.function.client.support.OAuth2WebClientHttpServiceGroupConfigurer; +import org.springframework.web.reactive.function.client.support.WebClientHttpServiceGroupConfigurer; +import org.springframework.web.service.registry.HttpServiceGroup; +import org.springframework.web.service.registry.ImportHttpServices; + +import static org.mockito.Mockito.mock; + +/** + * Documentation for {@link OAuth2RestClientHttpServiceGroupConfigurer}. + * @author Rob Winch + */ +@Configuration(proxyBeanMethods = false) +@ImportHttpServices(types = UserService.class, clientType = HttpServiceGroup.ClientType.WEB_CLIENT) +public class ServerWebClientHttpInterfaceIntegrationConfiguration { + + // tag::config[] + @Bean + OAuth2WebClientHttpServiceGroupConfigurer securityConfigurer( + ReactiveOAuth2AuthorizedClientManager manager) { + return OAuth2WebClientHttpServiceGroupConfigurer.from(manager); + } + // end::config[] + + @Bean + ReactiveOAuth2AuthorizedClientManager authorizedClientManager() { + return mock(ReactiveOAuth2AuthorizedClientManager.class); + } + + @Bean + WebClientHttpServiceGroupConfigurer groupConfigurer(MockWebServer server) { + return groups -> { + String baseUrl = server.url("").toString(); + groups + .forEachClient((group, builder) -> builder + .baseUrl(baseUrl) + .defaultHeader("Accept", "application/vnd.github.v3+json")); + }; + } + + @Bean + MockWebServer mockServer() { + return new MockWebServer(); + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/clientregistrationid/User.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/clientregistrationid/User.kt new file mode 100644 index 0000000000..bb99882ab6 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/clientregistrationid/User.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2002-2025 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 clients 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.security.kt.docs.features.integrations.rest.clientregistrationid + + +/** + * A user. + * @param login + * @param id + * @param name + * @author Rob Winch + * @see UserService + */ +@JvmRecord +data class User(val login: String, val id: Int, val name: String) diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/clientregistrationid/UserService.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/clientregistrationid/UserService.kt new file mode 100644 index 0000000000..e9dde63d6a --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/clientregistrationid/UserService.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2025 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 clients 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.security.kt.docs.features.integrations.rest.clientregistrationid + +import org.springframework.security.oauth2.client.annotation.ClientRegistrationId +import org.springframework.web.service.annotation.GetExchange +import org.springframework.web.service.annotation.HttpExchange + +/** + * Demonstrates a service for {@link ClientRegistrationId} and HTTP Interface clients. + * @author Rob Winch + */ +@HttpExchange +interface UserService { + + // tag::getAuthenticatedUser[] + @GetExchange("/user") + @ClientRegistrationId("github") + fun getAuthenticatedUser() : User + // end::getAuthenticatedUser[] +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfiguration.kt new file mode 100644 index 0000000000..d30e88a122 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfiguration.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2002-2025 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 clients 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.security.kt.docs.features.integrations.rest.configurationrestclient + +import okhttp3.mockwebserver.MockWebServer +import org.mockito.Mockito +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.kt.docs.features.integrations.rest.clientregistrationid.UserService +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager +import org.springframework.security.oauth2.client.web.client.support.OAuth2RestClientHttpServiceGroupConfigurer +import org.springframework.web.client.RestClient +import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer +import org.springframework.web.service.registry.HttpServiceGroup +import org.springframework.web.service.registry.HttpServiceGroupConfigurer +import org.springframework.web.service.registry.HttpServiceGroupConfigurer.ClientCallback +import org.springframework.web.service.registry.ImportHttpServices + +/** + * Documentation for [OAuth2RestClientHttpServiceGroupConfigurer]. + * @author Rob Winch + */ +@Configuration(proxyBeanMethods = false) +@ImportHttpServices(types = [UserService::class]) +class RestClientHttpInterfaceIntegrationConfiguration { + // tag::config[] + @Bean + fun securityConfigurer(manager: OAuth2AuthorizedClientManager): OAuth2RestClientHttpServiceGroupConfigurer { + return OAuth2RestClientHttpServiceGroupConfigurer.from(manager) + } + // end::config[] + + @Bean + fun authorizedClientManager(): OAuth2AuthorizedClientManager? { + return Mockito.mock(OAuth2AuthorizedClientManager::class.java) + } + + @Bean + fun groupConfigurer(server: MockWebServer): RestClientHttpServiceGroupConfigurer { + return RestClientHttpServiceGroupConfigurer { groups: HttpServiceGroupConfigurer.Groups -> + groups.forEachClient(ClientCallback { group: HttpServiceGroup, builder: RestClient.Builder -> + builder + .baseUrl(server.url("").toString()) + }) + } + } + + @Bean + fun mockServer(): MockWebServer { + return MockWebServer() + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfigurationTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfigurationTests.kt new file mode 100644 index 0000000000..85a395cd33 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfigurationTests.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2025 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 clients 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.security.kt.docs.features.integrations.rest.configurationrestclient + +import io.mockk.every +import io.mockk.mockkObject +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.config.oauth2.client.CommonOAuth2Provider +import org.springframework.security.kt.docs.features.integrations.rest.clientregistrationid.UserService +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager +import org.springframework.security.oauth2.core.OAuth2AccessToken +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.time.Duration +import java.time.Instant + +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [RestClientHttpInterfaceIntegrationConfiguration::class]) +internal class RestClientHttpInterfaceIntegrationConfigurationTests { + @Test + fun getAuthenticatedUser( + @Autowired webServer: MockWebServer, + @Autowired authorizedClients: OAuth2AuthorizedClientManager, + @Autowired users: UserService + ) { + val registration = CommonOAuth2Provider.GITHUB.getBuilder("github").clientId("github").build() + + val issuedAt = Instant.now() + val expiresAt = issuedAt.plus(Duration.ofMinutes(5)) + val token = OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, "1234", + issuedAt, expiresAt + ) + val result = OAuth2AuthorizedClient(registration, "rob", token) + mockkObject(authorizedClients) + every { + authorizedClients.authorize(any()) + } returns result + + webServer.enqueue( + MockResponse().addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).setBody( + """ + {"login": "rob_winch", "id": 1234, "name": "Rob Winch" } + """.trimIndent() + ) + ) + + users.getAuthenticatedUser() + + Assertions.assertThat(webServer.takeRequest().getHeader(HttpHeaders.AUTHORIZATION)) + .isEqualTo("Bearer " + token.getTokenValue()) + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationwebclient/ServerRestClientHttpInterfaceIntegrationConfigurationTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationwebclient/ServerRestClientHttpInterfaceIntegrationConfigurationTests.kt new file mode 100644 index 0000000000..9a5ed50ff9 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationwebclient/ServerRestClientHttpInterfaceIntegrationConfigurationTests.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2002-2025 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 clients 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.security.kt.docs.features.integrations.rest.configurationwebclient + +import io.mockk.every +import io.mockk.mockkObject +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.security.config.oauth2.client.CommonOAuth2Provider +import org.springframework.security.kt.docs.features.integrations.rest.clientregistrationid.UserService +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager +import org.springframework.security.oauth2.core.OAuth2AccessToken +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.junit.jupiter.SpringExtension +import reactor.core.publisher.Mono +import java.time.Duration +import java.time.Instant + +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [ServerWebClientHttpInterfaceIntegrationConfiguration::class]) +internal class ServerRestClientHttpInterfaceIntegrationConfigurationTests { + @Test + @Throws(InterruptedException::class) + fun getAuthenticatedUser( + @Autowired webServer: MockWebServer, + @Autowired authorizedClients: ReactiveOAuth2AuthorizedClientManager, + @Autowired users: UserService + ) { + val registration = CommonOAuth2Provider.GITHUB.getBuilder("github").clientId("github").build() + + val issuedAt = Instant.now() + val expiresAt = issuedAt.plus(Duration.ofMinutes(5)) + val token = OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, "1234", + issuedAt, expiresAt + ) + val result = OAuth2AuthorizedClient(registration, "rob", token) + mockkObject(authorizedClients) + every { + authorizedClients.authorize(any()) + } returns Mono.just(result) + + webServer.enqueue( + MockResponse().addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).setBody( + """ + {"login": "rob_winch", "id": 1234, "name": "Rob Winch" } + + """.trimIndent() + ) + ) + + users.getAuthenticatedUser() + + Assertions.assertThat(webServer.takeRequest().getHeader(HttpHeaders.AUTHORIZATION)) + .isEqualTo("Bearer " + token.getTokenValue()) + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationwebclient/ServerWebClientHttpInterfaceIntegrationConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationwebclient/ServerWebClientHttpInterfaceIntegrationConfiguration.kt new file mode 100644 index 0000000000..89fa67348e --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationwebclient/ServerWebClientHttpInterfaceIntegrationConfiguration.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2002-2025 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 clients 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.security.kt.docs.features.integrations.rest.configurationwebclient + +import okhttp3.mockwebserver.MockWebServer +import org.mockito.Mockito +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.kt.docs.features.integrations.rest.clientregistrationid.UserService +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager +import org.springframework.security.oauth2.client.web.client.support.OAuth2RestClientHttpServiceGroupConfigurer +import org.springframework.security.oauth2.client.web.reactive.function.client.support.OAuth2WebClientHttpServiceGroupConfigurer +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.support.WebClientHttpServiceGroupConfigurer +import org.springframework.web.service.registry.HttpServiceGroup +import org.springframework.web.service.registry.HttpServiceGroupConfigurer +import org.springframework.web.service.registry.HttpServiceGroupConfigurer.ClientCallback +import org.springframework.web.service.registry.ImportHttpServices + +/** + * Documentation for [OAuth2RestClientHttpServiceGroupConfigurer]. + * @author Rob Winch + */ +@Configuration(proxyBeanMethods = false) +@ImportHttpServices(types = [UserService::class], clientType = HttpServiceGroup.ClientType.WEB_CLIENT) +class ServerWebClientHttpInterfaceIntegrationConfiguration { + // tag::config[] + @Bean + fun securityConfigurer( + manager: ReactiveOAuth2AuthorizedClientManager? + ): OAuth2WebClientHttpServiceGroupConfigurer { + return OAuth2WebClientHttpServiceGroupConfigurer.from(manager) + } + + // end::config[] + @Bean + fun authorizedClientManager(): ReactiveOAuth2AuthorizedClientManager? { + return Mockito.mock(ReactiveOAuth2AuthorizedClientManager::class.java) + } + + @Bean + fun groupConfigurer(server: MockWebServer): WebClientHttpServiceGroupConfigurer { + return WebClientHttpServiceGroupConfigurer { groups: HttpServiceGroupConfigurer.Groups? -> + val baseUrl = server.url("").toString() + groups!! + .forEachClient(ClientCallback { group: HttpServiceGroup?, builder: WebClient.Builder? -> + builder!! + .baseUrl(baseUrl) + .defaultHeader("Accept", "application/vnd.github.v3+json") + }) + } + } + + @Bean + fun mockServer(): MockWebServer { + return MockWebServer() + } +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/annotation/ClientRegistrationId.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/annotation/ClientRegistrationId.java new file mode 100644 index 0000000000..30fac85fd3 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/annotation/ClientRegistrationId.java @@ -0,0 +1,58 @@ +/* + * Copyright 2002-2025 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.security.oauth2.client.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * This annotation can be added to the method of an interface based HTTP client created + * using {@link org.springframework.web.service.invoker.HttpServiceProxyFactory} to + * automatically associate an OAuth token with the request. + * + * @author Rob Winch + * @since 7.0 + * @see org.springframework.security.oauth2.client.web.client.ClientRegistrationIdProcessor + */ +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ClientRegistrationId { + + /** + * Sets the client registration identifier. + * @return the client registration identifier + */ + @AliasFor("value") + String registrationId() default ""; + + /** + * The default attribute for this annotation. This is an alias for + * {@link #registrationId()}. For example, + * {@code @RegisteredOAuth2AuthorizedClient("login-client")} is equivalent to + * {@code @RegisteredOAuth2AuthorizedClient(registrationId="login-client")}. + * @return the client registration identifier + */ + @AliasFor("registrationId") + String value() default ""; + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/ClientAttributes.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/ClientAttributes.java new file mode 100644 index 0000000000..6075ca6626 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/ClientAttributes.java @@ -0,0 +1,69 @@ +/* + * Copyright 2002-2025 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.security.oauth2.client.web; + +import java.util.Map; +import java.util.function.Consumer; + +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.util.Assert; + +/** + * Used for accessing the attribute that stores the the + * {@link ClientRegistration#getRegistrationId()}. This ensures that + * {@link org.springframework.security.oauth2.client.web.client.ClientRegistrationIdProcessor} + * aligns with all of ways of setting on both + * {@link org.springframework.web.client.RestClient} and + * {@link org.springframework.web.reactive.function.client.WebClient}. + * + * @see org.springframework.security.oauth2.client.web.client.ClientRegistrationIdProcessor + * @see org.springframework.security.oauth2.client.web.client.RequestAttributeClientRegistrationIdResolver + * @see org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction + * @see org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction + */ +public final class ClientAttributes { + + private static final String CLIENT_REGISTRATION_ID_ATTR_NAME = ClientRegistration.class.getName() + .concat(".CLIENT_REGISTRATION_ID"); + + /** + * Resolves the {@link ClientRegistration#getRegistrationId() clientRegistrationId} to + * be used to look up the {@link OAuth2AuthorizedClient}. + * @param attributes the to search + * @return the registration id to use. + */ + public static String resolveClientRegistrationId(Map attributes) { + return (String) attributes.get(CLIENT_REGISTRATION_ID_ATTR_NAME); + } + + /** + * Produces a Consumer that adds the {@link ClientRegistration#getRegistrationId() + * clientRegistrationId} to be used to look up the {@link OAuth2AuthorizedClient}. + * @param clientRegistrationId the {@link ClientRegistration#getRegistrationId() + * clientRegistrationId} to be used to look up the {@link OAuth2AuthorizedClient} + * @return the {@link Consumer} to populate the attributes + */ + public static Consumer> clientRegistrationId(String clientRegistrationId) { + Assert.hasText(clientRegistrationId, "clientRegistrationId cannot be empty"); + return (attributes) -> attributes.put(CLIENT_REGISTRATION_ID_ATTR_NAME, clientRegistrationId); + } + + private ClientAttributes() { + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessor.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessor.java new file mode 100644 index 0000000000..ec6f6bedac --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessor.java @@ -0,0 +1,51 @@ +/* + * Copyright 2002-2025 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.security.oauth2.client.web.client; + +import java.lang.reflect.Method; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.security.oauth2.client.annotation.ClientRegistrationId; +import org.springframework.security.oauth2.client.web.ClientAttributes; +import org.springframework.web.service.invoker.HttpRequestValues; + +/** + * Invokes {@link ClientAttributes#clientRegistrationId(String)} with the value specified + * by {@link ClientRegistrationId} on the request. + * + * @author Rob Winch + * @since 7.0 + */ +public final class ClientRegistrationIdProcessor implements HttpRequestValues.Processor { + + public static ClientRegistrationIdProcessor DEFAULT_INSTANCE = new ClientRegistrationIdProcessor(); + + @Override + public void process(Method method, @Nullable Object[] arguments, HttpRequestValues.Builder builder) { + ClientRegistrationId registeredId = AnnotationUtils.findAnnotation(method, ClientRegistrationId.class); + if (registeredId != null) { + String registrationId = registeredId.registrationId(); + builder.configureAttributes(ClientAttributes.clientRegistrationId(registrationId)); + } + } + + private ClientRegistrationIdProcessor() { + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/RequestAttributeClientRegistrationIdResolver.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/RequestAttributeClientRegistrationIdResolver.java index f1031ae9c1..88f88fefd8 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/RequestAttributeClientRegistrationIdResolver.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/RequestAttributeClientRegistrationIdResolver.java @@ -23,7 +23,7 @@ import org.springframework.http.HttpRequest; import org.springframework.http.client.ClientHttpRequest; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.util.Assert; +import org.springframework.security.oauth2.client.web.ClientAttributes; /** * A strategy for resolving a {@code clientRegistrationId} from an intercepted request @@ -36,13 +36,9 @@ import org.springframework.util.Assert; public final class RequestAttributeClientRegistrationIdResolver implements OAuth2ClientHttpRequestInterceptor.ClientRegistrationIdResolver { - private static final String CLIENT_REGISTRATION_ID_ATTR_NAME = RequestAttributeClientRegistrationIdResolver.class - .getName() - .concat(".clientRegistrationId"); - @Override public String resolve(HttpRequest request) { - return (String) request.getAttributes().get(CLIENT_REGISTRATION_ID_ATTR_NAME); + return ClientAttributes.resolveClientRegistrationId(request.getAttributes()); } /** @@ -54,8 +50,7 @@ public final class RequestAttributeClientRegistrationIdResolver * @return the {@link Consumer} to populate the attributes */ public static Consumer> clientRegistrationId(String clientRegistrationId) { - Assert.hasText(clientRegistrationId, "clientRegistrationId cannot be empty"); - return (attributes) -> attributes.put(CLIENT_REGISTRATION_ID_ATTR_NAME, clientRegistrationId); + return ClientAttributes.clientRegistrationId(clientRegistrationId); } } diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/support/OAuth2RestClientHttpServiceGroupConfigurer.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/support/OAuth2RestClientHttpServiceGroupConfigurer.java new file mode 100644 index 0000000000..115c11ed72 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/support/OAuth2RestClientHttpServiceGroupConfigurer.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-2025 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.security.oauth2.client.web.client.support; + +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.client.ClientRegistrationIdProcessor; +import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer; +import org.springframework.web.service.invoker.HttpRequestValues; + +/** + * Simplify adding OAuth2 support to interface based rest clients that use + * {@link RestClient}. + * + * It will add {@link OAuth2ClientHttpRequestInterceptor} to the {@link RestClient} and + * {@link ClientRegistrationIdProcessor} to the + * {@link org.springframework.web.service.invoker.HttpServiceProxyFactory}. + * + * @author Rob Winch + * @since 7.0 + */ +public final class OAuth2RestClientHttpServiceGroupConfigurer implements RestClientHttpServiceGroupConfigurer { + + private final HttpRequestValues.Processor processor = ClientRegistrationIdProcessor.DEFAULT_INSTANCE; + + private final ClientHttpRequestInterceptor interceptor; + + private OAuth2RestClientHttpServiceGroupConfigurer(ClientHttpRequestInterceptor interceptor) { + this.interceptor = interceptor; + } + + @Override + public void configureGroups(Groups groups) { + // @formatter:off + groups.forEachClient((group, client) -> + client.requestInterceptor(this.interceptor) + ); + groups.forEachProxyFactory((group, factory) -> + factory.httpRequestValuesProcessor(this.processor) + ); + // @formatter:on + } + + public static OAuth2RestClientHttpServiceGroupConfigurer from( + OAuth2AuthorizedClientManager authorizedClientManager) { + OAuth2ClientHttpRequestInterceptor interceptor = new OAuth2ClientHttpRequestInterceptor( + authorizedClientManager); + return new OAuth2RestClientHttpServiceGroupConfigurer(interceptor); + } + +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServerOAuth2AuthorizedClientExchangeFilterFunction.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServerOAuth2AuthorizedClientExchangeFilterFunction.java index 2d9be5ebf4..6831a46961 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServerOAuth2AuthorizedClientExchangeFilterFunction.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServerOAuth2AuthorizedClientExchangeFilterFunction.java @@ -44,6 +44,7 @@ import org.springframework.security.oauth2.client.RemoveAuthorizedClientReactive import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.ClientAttributes; import org.springframework.security.oauth2.client.web.DefaultReactiveOAuth2AuthorizedClientManager; import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.core.OAuth2AuthorizationException; @@ -104,13 +105,6 @@ public final class ServerOAuth2AuthorizedClientExchangeFilterFunction implements */ private static final String OAUTH2_AUTHORIZED_CLIENT_ATTR_NAME = OAuth2AuthorizedClient.class.getName(); - /** - * The client request attribute name used to locate the - * {@link ClientRegistration#getRegistrationId()} - */ - private static final String CLIENT_REGISTRATION_ID_ATTR_NAME = OAuth2AuthorizedClient.class.getName() - .concat(".CLIENT_REGISTRATION_ID"); - /** * The request attribute name used to locate the * {@link org.springframework.web.server.ServerWebExchange}. @@ -292,7 +286,7 @@ public final class ServerOAuth2AuthorizedClientExchangeFilterFunction implements * @return the {@link Consumer} to populate the attributes */ public static Consumer> clientRegistrationId(String clientRegistrationId) { - return (attributes) -> attributes.put(CLIENT_REGISTRATION_ID_ATTR_NAME, clientRegistrationId); + return ClientAttributes.clientRegistrationId(clientRegistrationId); } private static String clientRegistrationId(ClientRequest request) { @@ -300,7 +294,7 @@ public final class ServerOAuth2AuthorizedClientExchangeFilterFunction implements if (authorizedClient != null) { return authorizedClient.getClientRegistration().getRegistrationId(); } - return (String) request.attributes().get(CLIENT_REGISTRATION_ID_ATTR_NAME); + return ClientAttributes.resolveClientRegistrationId(request.attributes()); } /** diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServletOAuth2AuthorizedClientExchangeFilterFunction.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServletOAuth2AuthorizedClientExchangeFilterFunction.java index db136ba11b..98c4370e25 100644 --- a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServletOAuth2AuthorizedClientExchangeFilterFunction.java +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServletOAuth2AuthorizedClientExchangeFilterFunction.java @@ -50,6 +50,7 @@ import org.springframework.security.oauth2.client.RemoveAuthorizedClientOAuth2Au import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.ClientAttributes; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager; import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.core.OAuth2AuthorizationException; @@ -136,9 +137,6 @@ public final class ServletOAuth2AuthorizedClientExchangeFilterFunction implement */ private static final String OAUTH2_AUTHORIZED_CLIENT_ATTR_NAME = OAuth2AuthorizedClient.class.getName(); - private static final String CLIENT_REGISTRATION_ID_ATTR_NAME = OAuth2AuthorizedClient.class.getName() - .concat(".CLIENT_REGISTRATION_ID"); - private static final String AUTHENTICATION_ATTR_NAME = Authentication.class.getName(); private static final String HTTP_SERVLET_REQUEST_ATTR_NAME = HttpServletRequest.class.getName(); @@ -311,7 +309,7 @@ public final class ServletOAuth2AuthorizedClientExchangeFilterFunction implement * @return the {@link Consumer} to populate the attributes */ public static Consumer> clientRegistrationId(String clientRegistrationId) { - return (attributes) -> attributes.put(CLIENT_REGISTRATION_ID_ATTR_NAME, clientRegistrationId); + return ClientAttributes.clientRegistrationId(clientRegistrationId); } /** @@ -536,7 +534,7 @@ public final class ServletOAuth2AuthorizedClientExchangeFilterFunction implement } static String getClientRegistrationId(Map attrs) { - return (String) attrs.get(CLIENT_REGISTRATION_ID_ATTR_NAME); + return ClientAttributes.resolveClientRegistrationId(attrs); } static Authentication getAuthentication(Map attrs) { diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/support/OAuth2WebClientHttpServiceGroupConfigurer.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/support/OAuth2WebClientHttpServiceGroupConfigurer.java new file mode 100644 index 0000000000..78bf566fa3 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/support/OAuth2WebClientHttpServiceGroupConfigurer.java @@ -0,0 +1,92 @@ +/* + * Copyright 2002-2025 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.security.oauth2.client.web.reactive.function.client.support; + +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.client.ClientRegistrationIdProcessor; +import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction; +import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientHttpServiceGroupConfigurer; +import org.springframework.web.service.invoker.HttpRequestValues; + +/** + * Simplify adding OAuth2 support to interface based rest clients that use + * {@link WebClient}. + * + * @author Rob Winch + * @since 7.0 + */ +public final class OAuth2WebClientHttpServiceGroupConfigurer implements WebClientHttpServiceGroupConfigurer { + + private final HttpRequestValues.Processor processor = ClientRegistrationIdProcessor.DEFAULT_INSTANCE; + + private final ExchangeFilterFunction filter; + + private OAuth2WebClientHttpServiceGroupConfigurer(ExchangeFilterFunction filter) { + this.filter = filter; + } + + @Override + public void configureGroups(Groups groups) { + // @formatter:off + groups.forEachClient((group, client) -> + client.filter(this.filter) + ); + groups.forEachProxyFactory((group, factory) -> + factory.httpRequestValuesProcessor(this.processor) + ); + // @formatter:on + } + + /** + * Create an instance for Reactive web applications from the provided + * {@link ReactiveOAuth2AuthorizedClientManager}. + * + * It will add {@link ServerOAuth2AuthorizedClientExchangeFilterFunction} to the + * {@link WebClient} and {@link ClientRegistrationIdProcessor} to the + * {@link org.springframework.web.service.invoker.HttpServiceProxyFactory}. + * @param authorizedClientManager the manager to use. + * @return the {@link OAuth2WebClientHttpServiceGroupConfigurer}. + */ + public static OAuth2WebClientHttpServiceGroupConfigurer from( + ReactiveOAuth2AuthorizedClientManager authorizedClientManager) { + ServerOAuth2AuthorizedClientExchangeFilterFunction filter = new ServerOAuth2AuthorizedClientExchangeFilterFunction( + authorizedClientManager); + return new OAuth2WebClientHttpServiceGroupConfigurer(filter); + } + + /** + * Create an instance for Servlet based environments from the provided + * {@link OAuth2AuthorizedClientManager}. + * + * It will add {@link ServletOAuth2AuthorizedClientExchangeFilterFunction} to the + * {@link WebClient} and {@link ClientRegistrationIdProcessor} to the + * {@link org.springframework.web.service.invoker.HttpServiceProxyFactory}. + * @param authorizedClientManager the manager to use. + * @return the {@link OAuth2WebClientHttpServiceGroupConfigurer}. + */ + public static OAuth2WebClientHttpServiceGroupConfigurer from( + OAuth2AuthorizedClientManager authorizedClientManager) { + ServletOAuth2AuthorizedClientExchangeFilterFunction filter = new ServletOAuth2AuthorizedClientExchangeFilterFunction( + authorizedClientManager); + return new OAuth2WebClientHttpServiceGroupConfigurer(filter); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/AbstractMockServerClientRegistrationIdProcessorTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/AbstractMockServerClientRegistrationIdProcessorTests.java new file mode 100644 index 0000000000..c30ea20e99 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/AbstractMockServerClientRegistrationIdProcessorTests.java @@ -0,0 +1,108 @@ +/* + * Copyright 2002-2025 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.security.oauth2.client.web.client; + +import java.io.IOException; + +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import org.springframework.http.HttpHeaders; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.annotation.ClientRegistrationId; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.TestOAuth2AccessTokens; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.invoker.HttpExchangeAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Base class for integration testing {@link ClientRegistrationIdProcessor} with + * {@link MockWebServer}. + * + * @author Rob Winch + * @since 7.0 + */ +abstract class AbstractMockServerClientRegistrationIdProcessorTests { + + static final String REGISTRATION_ID = "okta"; + + private final MockWebServer server = new MockWebServer(); + + private OAuth2AccessToken accessToken; + + protected String baseUrl; + + protected OAuth2AuthorizedClient authorizedClient; + + @BeforeEach + void setup() throws IOException { + this.server.start(); + HttpUrl url = this.server.url("/range/"); + this.baseUrl = url.toString(); + ClientRegistration clientRegistration = TestClientRegistrations.clientRegistration() + .registrationId(REGISTRATION_ID) + .build(); + this.accessToken = TestOAuth2AccessTokens.scopes("read", "write"); + this.authorizedClient = new OAuth2AuthorizedClient(clientRegistration, "user", this.accessToken); + } + + @AfterEach + void cleanup() throws IOException { + if (this.server != null) { + this.server.shutdown(); + } + } + + void testWithAdapter(HttpExchangeAdapter adapter) throws InterruptedException { + ClientRegistrationIdProcessor processor = ClientRegistrationIdProcessor.DEFAULT_INSTANCE; + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builder() + .exchangeAdapter(adapter) + .httpRequestValuesProcessor(processor) + .build(); + MessageClient messages = factory.createClient(MessageClient.class); + + this.server.enqueue(new MockResponse().setBody("Hello OAuth2!").setResponseCode(200)); + assertThat(messages.getMessage()).isEqualTo("Hello OAuth2!"); + + String authorizationHeader = this.server.takeRequest().getHeader(HttpHeaders.AUTHORIZATION); + assertOAuthTokenValue(authorizationHeader, this.accessToken); + + } + + private static void assertOAuthTokenValue(String value, OAuth2AccessToken accessToken) { + String tokenType = accessToken.getTokenType().getValue(); + String tokenValue = accessToken.getTokenValue(); + assertThat(value).isEqualTo("%s %s".formatted(tokenType, tokenValue)); + } + + interface MessageClient { + + @GetExchange("/message") + @ClientRegistrationId(REGISTRATION_ID) + String getMessage(); + + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorRestClientTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorRestClientTests.java new file mode 100644 index 0000000000..a096327bae --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorRestClientTests.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2025 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.security.oauth2.client.web.client; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +/** + * Runs tests of {@link ClientRegistrationIdProcessor} with {@link RestClient} to ensure + * that all the parts work together properly. + * + * @author Rob Winch + * @since 7.0 + */ +@ExtendWith(MockitoExtension.class) +class ClientRegistrationIdProcessorRestClientTests extends AbstractMockServerClientRegistrationIdProcessorTests { + + @Mock + private OAuth2AuthorizedClientManager authorizedClientManager; + + @Test + void clientRegistrationIdProcessorWorksWithRestClientAdapter() throws InterruptedException { + OAuth2ClientHttpRequestInterceptor interceptor = new OAuth2ClientHttpRequestInterceptor( + this.authorizedClientManager); + RestClient.Builder builder = RestClient.builder().requestInterceptor(interceptor).baseUrl(this.baseUrl); + + ArgumentCaptor authorizeRequest = ArgumentCaptor.forClass(OAuth2AuthorizeRequest.class); + given(this.authorizedClientManager.authorize(authorizeRequest.capture())).willReturn(authorizedClient); + + testWithAdapter(RestClientAdapter.create(builder.build())); + + assertThat(authorizeRequest.getValue().getClientRegistrationId()).isEqualTo(REGISTRATION_ID); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorTests.java new file mode 100644 index 0000000000..19ea292665 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2002-2025 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.security.oauth2.client.web.client; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.oauth2.client.annotation.ClientRegistrationId; +import org.springframework.security.oauth2.client.web.ClientAttributes; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.service.invoker.HttpRequestValues; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ClientRegistrationIdProcessor}. + * + * @author Rob Winch + * @since 7.0 + * @see ClientRegistrationIdProcessorWebClientTests + * @see ClientRegistrationIdProcessorRestClientTests + */ +class ClientRegistrationIdProcessorTests { + + ClientRegistrationIdProcessor processor = ClientRegistrationIdProcessor.DEFAULT_INSTANCE; + + @Test + void processWhenClientRegistrationIdPresentThenSet() { + HttpRequestValues.Builder builder = HttpRequestValues.builder(); + Method hasClientRegistrationId = ReflectionUtils.findMethod(RestService.class, "hasClientRegistrationId"); + this.processor.process(hasClientRegistrationId, null, builder); + + String registrationId = ClientAttributes.resolveClientRegistrationId(builder.build().getAttributes()); + assertThat(registrationId).isEqualTo(RestService.REGISTRATION_ID); + } + + @Test + void processWhenMetaClientRegistrationIdPresentThenSet() { + HttpRequestValues.Builder builder = HttpRequestValues.builder(); + Method hasClientRegistrationId = ReflectionUtils.findMethod(RestService.class, "hasMetaClientRegistrationId"); + this.processor.process(hasClientRegistrationId, null, builder); + + String registrationId = ClientAttributes.resolveClientRegistrationId(builder.build().getAttributes()); + assertThat(registrationId).isEqualTo(RestService.REGISTRATION_ID); + } + + @Test + void processWhenNoClientRegistrationIdPresentThenNull() { + HttpRequestValues.Builder builder = HttpRequestValues.builder(); + Method hasClientRegistrationId = ReflectionUtils.findMethod(RestService.class, "noClientRegistrationId"); + this.processor.process(hasClientRegistrationId, null, builder); + + String registrationId = ClientAttributes.resolveClientRegistrationId(builder.build().getAttributes()); + assertThat(registrationId).isNull(); + } + + interface RestService { + + String REGISTRATION_ID = "registrationId"; + + @ClientRegistrationId(REGISTRATION_ID) + void hasClientRegistrationId(); + + @MetaClientRegistrationId + void hasMetaClientRegistrationId(); + + void noClientRegistrationId(); + + } + + @Retention(RetentionPolicy.RUNTIME) + @ClientRegistrationId(RestService.REGISTRATION_ID) + @interface MetaClientRegistrationId { + + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorWebClientTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorWebClientTests.java new file mode 100644 index 0000000000..459264c92f --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorWebClientTests.java @@ -0,0 +1,82 @@ +/* + * Copyright 2002-2025 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.security.oauth2.client.web.client; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; + +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction; +import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.support.WebClientAdapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Runs tests for {@link ClientRegistrationIdProcessor} with {@link WebClient} to ensure + * that all the parts work together properly. + * + * @author Rob Winch + * @since 7.0 + */ +@ExtendWith(MockitoExtension.class) +class ClientRegistrationIdProcessorWebClientTests extends AbstractMockServerClientRegistrationIdProcessorTests { + + @Test + void clientRegistrationIdProcessorWorksWithReactiveWebClient() throws InterruptedException { + ReactiveOAuth2AuthorizedClientManager authorizedClientManager = mock( + ReactiveOAuth2AuthorizedClientManager.class); + ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2Client = new ServerOAuth2AuthorizedClientExchangeFilterFunction( + authorizedClientManager); + + WebClient.Builder builder = WebClient.builder().filter(oauth2Client).baseUrl(this.baseUrl); + + ArgumentCaptor authorizeRequest = ArgumentCaptor.forClass(OAuth2AuthorizeRequest.class); + given(authorizedClientManager.authorize(authorizeRequest.capture())) + .willReturn(Mono.just(this.authorizedClient)); + + testWithAdapter(WebClientAdapter.create(builder.build())); + + assertThat(authorizeRequest.getValue().getClientRegistrationId()).isEqualTo(REGISTRATION_ID); + } + + @Test + void clientRegistrationIdProcessorWorksWithServletWebClient() throws InterruptedException { + OAuth2AuthorizedClientManager authorizedClientManager = mock(OAuth2AuthorizedClientManager.class); + + ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client = new ServletOAuth2AuthorizedClientExchangeFilterFunction( + authorizedClientManager); + + WebClient.Builder builder = WebClient.builder().filter(oauth2Client).baseUrl(this.baseUrl); + + ArgumentCaptor authorizeRequest = ArgumentCaptor.forClass(OAuth2AuthorizeRequest.class); + given(authorizedClientManager.authorize(authorizeRequest.capture())).willReturn(this.authorizedClient); + + testWithAdapter(WebClientAdapter.create(builder.build())); + + assertThat(authorizeRequest.getValue().getClientRegistrationId()).isEqualTo(REGISTRATION_ID); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/support/OAuth2RestClientHttpServiceGroupConfigurerTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/support/OAuth2RestClientHttpServiceGroupConfigurerTests.java new file mode 100644 index 0000000000..489b4a042d --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/support/OAuth2RestClientHttpServiceGroupConfigurerTests.java @@ -0,0 +1,89 @@ +/* + * Copyright 2002-2025 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.security.oauth2.client.web.client.support; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.client.ClientRegistrationIdProcessor; +import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor; +import org.springframework.web.client.RestClient; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import org.springframework.web.service.registry.HttpServiceGroupConfigurer; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +/** + * Tests {@link OAuth2RestClientHttpServiceGroupConfigurer}. + * + * @author Rob Winch + */ +@ExtendWith(MockitoExtension.class) +class OAuth2RestClientHttpServiceGroupConfigurerTests { + + @Mock + private OAuth2AuthorizedClientManager authoriedClientManager; + + @Mock + private HttpServiceGroupConfigurer.Groups groups; + + @Captor + ArgumentCaptor forProxyFactory; + + @Mock + private HttpServiceProxyFactory.Builder factoryBuilder; + + @Captor + private ArgumentCaptor> configureClient; + + @Mock + private RestClient.Builder clientBuilder; + + @Test + void configureGroupsConfigureProxyFactory() { + + OAuth2RestClientHttpServiceGroupConfigurer configurer = OAuth2RestClientHttpServiceGroupConfigurer + .from(this.authoriedClientManager); + + configurer.configureGroups(this.groups); + verify(this.groups).forEachProxyFactory(this.forProxyFactory.capture()); + + this.forProxyFactory.getValue().withProxyFactory(null, this.factoryBuilder); + + verify(this.factoryBuilder).httpRequestValuesProcessor(ClientRegistrationIdProcessor.DEFAULT_INSTANCE); + } + + @Test + void configureGroupsConfigureClient() { + OAuth2RestClientHttpServiceGroupConfigurer configurer = OAuth2RestClientHttpServiceGroupConfigurer + .from(this.authoriedClientManager); + + configurer.configureGroups(this.groups); + verify(this.groups).forEachClient(this.configureClient.capture()); + + this.configureClient.getValue().withClient(null, this.clientBuilder); + + verify(this.clientBuilder).requestInterceptor(any(OAuth2ClientHttpRequestInterceptor.class)); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/reactive/function/client/support/OAuth2WebClientHttpServiceGroupConfigurerTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/reactive/function/client/support/OAuth2WebClientHttpServiceGroupConfigurerTests.java new file mode 100644 index 0000000000..058d434386 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/reactive/function/client/support/OAuth2WebClientHttpServiceGroupConfigurerTests.java @@ -0,0 +1,89 @@ +/* + * Copyright 2002-2025 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.security.oauth2.client.web.reactive.function.client.support; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.client.ClientRegistrationIdProcessor; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; +import org.springframework.web.service.registry.HttpServiceGroupConfigurer; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +/** + * Tests {@link OAuth2WebClientHttpServiceGroupConfigurer}. + * + * @author Rob Winch + */ +@ExtendWith(MockitoExtension.class) +class OAuth2WebClientHttpServiceGroupConfigurerTests { + + @Mock + private OAuth2AuthorizedClientManager authoriedClientManager; + + @Mock + private HttpServiceGroupConfigurer.Groups groups; + + @Captor + ArgumentCaptor forProxyFactory; + + @Mock + private HttpServiceProxyFactory.Builder factoryBuilder; + + @Captor + private ArgumentCaptor> configureClient; + + @Mock + private WebClient.Builder clientBuilder; + + @Test + void configureGroupsConfigureProxyFactory() { + + OAuth2WebClientHttpServiceGroupConfigurer configurer = OAuth2WebClientHttpServiceGroupConfigurer + .from(this.authoriedClientManager); + + configurer.configureGroups(this.groups); + verify(this.groups).forEachProxyFactory(this.forProxyFactory.capture()); + + this.forProxyFactory.getValue().withProxyFactory(null, this.factoryBuilder); + + verify(this.factoryBuilder).httpRequestValuesProcessor(ClientRegistrationIdProcessor.DEFAULT_INSTANCE); + } + + @Test + void configureGroupsConfigureClient() { + OAuth2WebClientHttpServiceGroupConfigurer configurer = OAuth2WebClientHttpServiceGroupConfigurer + .from(this.authoriedClientManager); + + configurer.configureGroups(this.groups); + verify(this.groups).forEachClient(this.configureClient.capture()); + + this.configureClient.getValue().withClient(null, this.clientBuilder); + + verify(this.clientBuilder).filter(any(ExchangeFilterFunction.class)); + } + +}