Procházet zdrojové kódy

Add OAuth Support for HTTP Interface Client

Closes gh-16858
Rob Winch před 3 měsíci
rodič
revize
b2325e4176
31 změnil soubory, kde provedl 1647 přidání a 22 odebrání
  1. 2 0
      docs/modules/ROOT/nav.adoc
  2. 66 0
      docs/modules/ROOT/pages/features/integrations/rest/http-interface.adoc
  3. 5 0
      docs/modules/ROOT/pages/servlet/oauth2/client/authorized-clients.adoc
  4. 1 0
      docs/modules/ROOT/pages/whats-new.adoc
  5. 3 0
      docs/spring-security-docs.gradle
  6. 28 0
      docs/src/test/java/org/springframework/security/docs/features/integrations/rest/clientregistrationid/User.java
  7. 36 0
      docs/src/test/java/org/springframework/security/docs/features/integrations/rest/clientregistrationid/UserService.java
  8. 67 0
      docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfiguration.java
  9. 73 0
      docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfigurationTests.java
  10. 75 0
      docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationwebclient/ServerRestClientHttpInterfaceIntegrationConfigurationTests.java
  11. 69 0
      docs/src/test/java/org/springframework/security/docs/features/integrations/rest/configurationwebclient/ServerWebClientHttpInterfaceIntegrationConfiguration.java
  12. 29 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/clientregistrationid/User.kt
  13. 35 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/clientregistrationid/UserService.kt
  14. 65 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfiguration.kt
  15. 75 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationrestclient/RestClientHttpInterfaceIntegrationConfigurationTests.kt
  16. 78 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationwebclient/ServerRestClientHttpInterfaceIntegrationConfigurationTests.kt
  17. 72 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/features/integrations/rest/configurationwebclient/ServerWebClientHttpInterfaceIntegrationConfiguration.kt
  18. 58 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/annotation/ClientRegistrationId.java
  19. 69 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/ClientAttributes.java
  20. 51 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessor.java
  21. 3 8
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/RequestAttributeClientRegistrationIdResolver.java
  22. 67 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/support/OAuth2RestClientHttpServiceGroupConfigurer.java
  23. 3 9
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServerOAuth2AuthorizedClientExchangeFilterFunction.java
  24. 3 5
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServletOAuth2AuthorizedClientExchangeFilterFunction.java
  25. 92 0
      oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/support/OAuth2WebClientHttpServiceGroupConfigurer.java
  26. 108 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/AbstractMockServerClientRegistrationIdProcessorTests.java
  27. 60 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorRestClientTests.java
  28. 94 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorTests.java
  29. 82 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/ClientRegistrationIdProcessorWebClientTests.java
  30. 89 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/client/support/OAuth2RestClientHttpServiceGroupConfigurerTests.java
  31. 89 0
      oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/reactive/function/client/support/OAuth2WebClientHttpServiceGroupConfigurerTests.java

+ 2 - 0
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]

+ 66 - 0
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`.
+

+ 5 - 0
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
 

+ 1 - 0
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]

+ 3 - 0
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"

+ 28 - 0
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) {
+}

+ 36 - 0
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[]
+
+}

+ 67 - 0
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();
+	}
+}

+ 73 - 0
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());
+	}
+
+}

+ 75 - 0
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());
+	}
+
+}

+ 69 - 0
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();
+	}
+}

+ 29 - 0
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)

+ 35 - 0
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[]
+}

+ 65 - 0
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?>(OAuth2AuthorizedClientManager::class.java)
+    }
+
+    @Bean
+    fun groupConfigurer(server: MockWebServer): RestClientHttpServiceGroupConfigurer {
+        return RestClientHttpServiceGroupConfigurer { groups: HttpServiceGroupConfigurer.Groups<RestClient.Builder> ->
+            groups.forEachClient(ClientCallback { group: HttpServiceGroup, builder: RestClient.Builder ->
+                    builder
+                        .baseUrl(server.url("").toString())
+                })
+        }
+    }
+
+    @Bean
+    fun mockServer(): MockWebServer {
+        return MockWebServer()
+    }
+}

+ 75 - 0
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())
+    }
+}

+ 78 - 0
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())
+    }
+}

+ 72 - 0
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?>(ReactiveOAuth2AuthorizedClientManager::class.java)
+    }
+
+    @Bean
+    fun groupConfigurer(server: MockWebServer): WebClientHttpServiceGroupConfigurer {
+        return WebClientHttpServiceGroupConfigurer { groups: HttpServiceGroupConfigurer.Groups<WebClient.Builder?>? ->
+            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()
+    }
+}

+ 58 - 0
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 "";
+
+}

+ 69 - 0
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<String, Object> 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<Map<String, Object>> clientRegistrationId(String clientRegistrationId) {
+		Assert.hasText(clientRegistrationId, "clientRegistrationId cannot be empty");
+		return (attributes) -> attributes.put(CLIENT_REGISTRATION_ID_ATTR_NAME, clientRegistrationId);
+	}
+
+	private ClientAttributes() {
+	}
+
+}

+ 51 - 0
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() {
+	}
+
+}

+ 3 - 8
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<Map<String, Object>> clientRegistrationId(String clientRegistrationId) {
-		Assert.hasText(clientRegistrationId, "clientRegistrationId cannot be empty");
-		return (attributes) -> attributes.put(CLIENT_REGISTRATION_ID_ATTR_NAME, clientRegistrationId);
+		return ClientAttributes.clientRegistrationId(clientRegistrationId);
 	}
 
 }

+ 67 - 0
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<RestClient.Builder> 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);
+	}
+
+}

+ 3 - 9
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<Map<String, Object>> 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());
 	}
 
 	/**

+ 3 - 5
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<Map<String, Object>> 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<String, Object> attrs) {
-		return (String) attrs.get(CLIENT_REGISTRATION_ID_ATTR_NAME);
+		return ClientAttributes.resolveClientRegistrationId(attrs);
 	}
 
 	static Authentication getAuthentication(Map<String, Object> attrs) {

+ 92 - 0
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<WebClient.Builder> 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);
+	}
+
+}

+ 108 - 0
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();
+
+	}
+
+}

+ 60 - 0
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<OAuth2AuthorizeRequest> 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);
+	}
+
+}

+ 94 - 0
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 {
+
+	}
+
+}

+ 82 - 0
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<OAuth2AuthorizeRequest> 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<OAuth2AuthorizeRequest> 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);
+	}
+
+}

+ 89 - 0
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<RestClient.Builder> groups;
+
+	@Captor
+	ArgumentCaptor<HttpServiceGroupConfigurer.ProxyFactoryCallback> forProxyFactory;
+
+	@Mock
+	private HttpServiceProxyFactory.Builder factoryBuilder;
+
+	@Captor
+	private ArgumentCaptor<HttpServiceGroupConfigurer.ClientCallback<RestClient.Builder>> 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));
+	}
+
+}

+ 89 - 0
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<WebClient.Builder> groups;
+
+	@Captor
+	ArgumentCaptor<HttpServiceGroupConfigurer.ProxyFactoryCallback> forProxyFactory;
+
+	@Mock
+	private HttpServiceProxyFactory.Builder factoryBuilder;
+
+	@Captor
+	private ArgumentCaptor<HttpServiceGroupConfigurer.ClientCallback<WebClient.Builder>> 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));
+	}
+
+}