diff --git a/docs/modules/ROOT/pages/servlet/oauth2/index.adoc b/docs/modules/ROOT/pages/servlet/oauth2/index.adoc index c1e81238f5..60a34b84a8 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/index.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/index.adoc @@ -398,13 +398,14 @@ See xref:getting-spring-security.adoc[] for additional options when not using Sp Consider the following use cases for OAuth2 Client: -* <> -* <> -* <> (log users in _and_ access a third-party API) -* <> -* <> -* <> -* <> +* I want to <> +* I want to <> +* I want to <> (log users in _and_ access a third-party API) +* I want to <> +* I want to <> +* I want to <> +* I want to <> +* I want to <> [[oauth2-client-log-users-in]] === Log Users In with OAuth2 @@ -584,38 +585,11 @@ Spring Security provides implementations of `OAuth2AuthorizedClientManager` for Spring Security registers a default `OAuth2AuthorizedClientManager` bean for you when one does not exist. ==== -The easiest way to use an `OAuth2AuthorizedClientManager` is via an `ExchangeFilterFunction` that intercepts requests through a `WebClient`. -To use `WebClient`, you will need to add the `spring-webflux` dependency along with a reactive client implementation: +The easiest way to use an `OAuth2AuthorizedClientManager` is via a `ClientHttpRequestInterceptor` that intercepts requests through a `RestClient`, which is already available when `spring-web` is on the classpath. -.Add Spring WebFlux Dependency -[tabs] -====== -Gradle:: -+ -[source,gradle,role="primary"] ----- -implementation 'org.springframework:spring-webflux' -implementation 'io.projectreactor.netty:reactor-netty' ----- +The following example uses the default `OAuth2AuthorizedClientManager` to configure a `RestClient` capable of accessing protected resources by placing `Bearer` tokens in the `Authorization` header of each request: -Maven:: -+ -[source,maven,role="secondary"] ----- - - org.springframework - spring-webflux - - - io.projectreactor.netty - reactor-netty - ----- -====== - -The following example uses the default `OAuth2AuthorizedClientManager` to configure a `WebClient` capable of accessing protected resources by placing `Bearer` tokens in the `Authorization` header of each request: - -.Configure `WebClient` with `ExchangeFilterFunction` +.Configure `RestClient` with `ClientHttpRequestInterceptor` [tabs] ===== Java:: @@ -623,14 +597,14 @@ Java:: [source,java,role="primary"] ---- @Configuration -public class WebClientConfig { +public class RestClientConfig { @Bean - public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) { - ServletOAuth2AuthorizedClientExchangeFilterFunction filter = - new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager); - return WebClient.builder() - .apply(filter.oauth2Configuration()) + public RestClient restClient(OAuth2AuthorizedClientManager authorizedClientManager) { + OAuth2ClientHttpRequestInterceptor requestInterceptor = + new OAuth2ClientHttpRequestInterceptor(authorizedClientManager); + return RestClient.builder() + .requestInterceptor(requestInterceptor) .build(); } @@ -642,13 +616,13 @@ Kotlin:: [source,kotlin,role="secondary"] ---- @Configuration -class WebClientConfig { +class RestClientConfig { @Bean - fun webClient(authorizedClientManager: OAuth2AuthorizedClientManager): WebClient { - val filter = ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager) - return WebClient.builder() - .apply(filter.oauth2Configuration()) + fun restClient(authorizedClientManager: OAuth2AuthorizedClientManager): RestClient { + val requestInterceptor = OAuth2ClientHttpRequestInterceptor(authorizedClientManager) + return RestClient.builder() + .requestInterceptor(requestInterceptor) .build() } @@ -656,35 +630,35 @@ class WebClientConfig { ---- ===== -This configured `WebClient` can be used as in the following example: +This configured `RestClient` can be used as in the following example: [[oauth2-client-accessing-protected-resources-example]] -.Use `WebClient` to Access Protected Resources +.Use `RestClient` to Access Protected Resources [tabs] ===== Java:: + [source,java,role="primary"] ---- -import static org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.clientRegistrationId; +import static org.springframework.security.oauth2.client.web.client.RequestAttributeClientRegistrationIdResolver.clientRegistrationId; @RestController public class MessagesController { - private final WebClient webClient; + private final RestClient restClient; - public MessagesController(WebClient webClient) { - this.webClient = webClient; + public MessagesController(RestClient restClient) { + this.restClient = restClient; } @GetMapping("/messages") public ResponseEntity> messages() { - return this.webClient.get() + Message[] messages = this.restClient.get() .uri("http://localhost:8090/messages") .attributes(clientRegistrationId("my-oauth2-client")) .retrieve() - .toEntityList(Message.class) - .block(); + .body(Message[].class); + return ResponseEntity.ok(Arrays.asList(messages)); } public record Message(String message) { @@ -697,19 +671,21 @@ Kotlin:: + [source,kotlin,role="secondary"] ---- -import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.clientRegistrationId +import org.springframework.security.oauth2.client.web.client.RequestAttributeClientRegistrationIdResolver.clientRegistrationId +import org.springframework.web.client.body @RestController -class MessagesController(private val webClient: WebClient) { +class MessagesController(private val restClient: RestClient) { @GetMapping("/messages") fun messages(): ResponseEntity> { - return webClient.get() + val messages = restClient.get() .uri("http://localhost:8090/messages") .attributes(clientRegistrationId("my-oauth2-client")) .retrieve() - .toEntityList() - .block()!! + .body>()!! + .toList() + return ResponseEntity.ok(messages) } data class Message(val message: String) @@ -815,7 +791,227 @@ Spring Security provides implementations of `OAuth2AuthorizedClientManager` for Spring Security registers a default `OAuth2AuthorizedClientManager` bean for you when one does not exist. ==== -The easiest way to use an `OAuth2AuthorizedClientManager` is via an `ExchangeFilterFunction` that intercepts requests through a `WebClient`. +The easiest way to use an `OAuth2AuthorizedClientManager` is via a `ClientHttpRequestInterceptor` that intercepts requests through a `RestClient`, which is already available when `spring-web` is on the classpath. + +The following example uses the default `OAuth2AuthorizedClientManager` to configure a `RestClient` capable of accessing protected resources by placing `Bearer` tokens in the `Authorization` header of each request: + +.Configure `RestClient` with `ClientHttpRequestInterceptor` +[tabs] +===== +Java:: ++ +[source,java,role="primary"] +---- +@Configuration +public class RestClientConfig { + + @Bean + public RestClient restClient(OAuth2AuthorizedClientManager authorizedClientManager) { + OAuth2ClientHttpRequestInterceptor requestInterceptor = + new OAuth2ClientHttpRequestInterceptor(authorizedClientManager, clientRegistrationIdResolver()); + return RestClient.builder() + .requestInterceptor(requestInterceptor) + .build(); + } + + private static ClientRegistrationIdResolver clientRegistrationIdResolver() { + return (request) -> { + Authentication authentication = SecurityContextHolder.getAuthentication(); + return (authentication instanceof OAuth2AuthenticationToken principal) + ? principal.getAuthorizedClientRegistrationId() + : null; + }; + } + +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Configuration +class RestClientConfig { + + @Bean + fun restClient(authorizedClientManager: OAuth2AuthorizedClientManager): RestClient { + val requestInterceptor = OAuth2ClientHttpRequestInterceptor(authorizedClientManager, clientRegistrationIdResolver()) + return RestClient.builder() + .requestInterceptor(requestInterceptor) + .build() + } + + private fun clientRegistrationIdResolver(): OAuth2ClientHttpRequestInterceptor.ClientRegistrationIdResolver { + return OAuth2ClientHttpRequestInterceptor.ClientRegistrationIdResolver { request -> + val authentication = SecurityContextHolder.getContext().authentication + if (authentication is OAuth2AuthenticationToken) { + authentication.authorizedClientRegistrationId + } else { + null + } + } + } + +} +---- +===== + +This configured `RestClient` can be used as in the following example: + +[[oauth2-client-accessing-protected-resources-current-user-example]] +.Use `RestClient` to Access Protected Resources (Current User) +[tabs] +===== +Java:: ++ +[source,java,role="primary"] +---- +@RestController +public class MessagesController { + + private final RestClient restClient; + + public MessagesController(RestClient restClient) { + this.restClient = restClient; + } + + @GetMapping("/messages") + public ResponseEntity> messages() { + Message[] messages = this.restClient.get() + .uri("http://localhost:8090/messages") + .retrieve() + .body(Message[].class); + return ResponseEntity.ok(Arrays.asList(messages)); + } + + public record Message(String message) { + } + +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +import org.springframework.web.client.body + +@RestController +class MessagesController(private val restClient: RestClient) { + + @GetMapping("/messages") + fun messages(): ResponseEntity> { + val messages = restClient.get() + .uri("http://localhost:8090/messages") + .retrieve() + .body>()!! + .toList() + return ResponseEntity.ok(messages) + } + + data class Message(val message: String) + +} +---- +===== + +[NOTE] +==== +Unlike the <>, notice that we do not need to tell Spring Security about the `clientRegistrationId` we'd like to use. +This is because it can be derived from the currently logged in user. +==== + +[[oauth2-client-access-protected-resources-webclient]] +=== Access Protected Resources with `WebClient` + +Making requests to a third party API that is protected by OAuth2 is a core use case of OAuth2 Client. +This is accomplished by authorizing a client (represented by the `OAuth2AuthorizedClient` class in Spring Security) and accessing protected resources by placing a `Bearer` token in the `Authorization` header of an outbound request. + +The following example configures the application to act as an OAuth2 Client capable of requesting protected resources from a third party API: + +.Configure OAuth2 Client +[tabs] +===== +Java:: ++ +[source,java,role="primary"] +---- +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // ... + .oauth2Client(Customizer.withDefaults()); + return http.build(); + } + +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +import org.springframework.security.config.annotation.web.invoke + +@Configuration +@EnableWebSecurity +class SecurityConfig { + + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + // ... + oauth2Client { } + } + + return http.build() + } + +} +---- +===== + +[NOTE] +==== +The above example does not provide a way to log users in. +You can use any other login mechanism (such as `formLogin()`). +See the <> for an example combining `oauth2Client()` with `oauth2Login()`. +==== + +In addition to the above configuration, the application requires at least one `ClientRegistration` to be configured through the use of a `ClientRegistrationRepository` bean. +The following example configures an `InMemoryClientRegistrationRepository` bean using Spring Boot configuration properties: + +[source,yaml] +---- +spring: + security: + oauth2: + client: + registration: + my-oauth2-client: + provider: my-auth-server + client-id: my-client-id + client-secret: my-client-secret + authorization-grant-type: authorization_code + scope: message.read,message.write + provider: + my-auth-server: + issuer-uri: https://my-auth-server.com +---- + +In addition to configuring Spring Security to support OAuth2 Client features, you will also need to decide how you will be accessing protected resources and configure your application accordingly. +Spring Security provides implementations of `OAuth2AuthorizedClientManager` for obtaining access tokens that can be used to access protected resources. + +[TIP] +==== +Spring Security registers a default `OAuth2AuthorizedClientManager` bean for you when one does not exist. +==== + +Another way to use an `OAuth2AuthorizedClientManager` is via an `ExchangeFilterFunction` that intercepts requests through a `WebClient`. To use `WebClient`, you will need to add the `spring-webflux` dependency along with a reactive client implementation: .Add Spring WebFlux Dependency @@ -889,14 +1085,15 @@ class WebClientConfig { This configured `WebClient` can be used as in the following example: -[[oauth2-client-accessing-protected-resources-current-user-example]] -.Use `WebClient` to Access Protected Resources (Current User) +.Use `WebClient` to Access Protected Resources [tabs] ===== Java:: + [source,java,role="primary"] ---- +import static org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.clientRegistrationId; + @RestController public class MessagesController { @@ -910,6 +1107,7 @@ public class MessagesController { public ResponseEntity> messages() { return this.webClient.get() .uri("http://localhost:8090/messages") + .attributes(clientRegistrationId("my-oauth2-client")) .retrieve() .toEntityList(Message.class) .block(); @@ -925,6 +1123,8 @@ Kotlin:: + [source,kotlin,role="secondary"] ---- +import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.clientRegistrationId + @RestController class MessagesController(private val webClient: WebClient) { @@ -932,6 +1132,7 @@ class MessagesController(private val webClient: WebClient) { fun messages(): ResponseEntity> { return webClient.get() .uri("http://localhost:8090/messages") + .attributes(clientRegistrationId("my-oauth2-client")) .retrieve() .toEntityList() .block()!! @@ -943,12 +1144,6 @@ class MessagesController(private val webClient: WebClient) { ---- ===== -[NOTE] -==== -Unlike the <>, notice that we do not need to tell Spring Security about the `clientRegistrationId` we'd like to use. -This is because it can be derived from the currently logged in user. -==== - [[oauth2-client-enable-extension-grant-type]] === Enable an Extension Grant Type diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/OAuth2ClientHttpRequestInterceptor.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/OAuth2ClientHttpRequestInterceptor.java new file mode 100644 index 0000000000..2a6128d971 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/OAuth2ClientHttpRequestInterceptor.java @@ -0,0 +1,381 @@ +/* + * Copyright 2002-2024 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 java.util.HashMap; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.oauth2.client.ClientAuthorizationException; +import org.springframework.security.oauth2.client.OAuth2AuthorizationFailureHandler; +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.RemoveAuthorizedClientOAuth2AuthorizationFailureHandler; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * Provides an easy mechanism for using an {@link OAuth2AuthorizedClient} to make OAuth + * 2.0 requests by including the {@link OAuth2AuthorizedClient#getAccessToken() access + * token} as a bearer token. + * + *

+ * Example usage: + * + *

+ * OAuth2ClientHttpRequestInterceptor requestInterceptor =
+ *     new OAuth2ClientHttpRequestInterceptor(authorizedClientManager);
+ * RestClient restClient = RestClient.builder()
+ *     .requestInterceptor(requestInterceptor)
+ *     .build();
+ * String response = restClient.get()
+ *     .uri(uri)
+ *     .retrieve()
+ *     .body(String.class);
+ * 
+ * + *

Authentication and Authorization Failures

+ * + *

+ * This interceptor has the ability to forward authentication (HTTP 401 Unauthorized) and + * authorization (HTTP 403 Forbidden) failures from an OAuth 2.0 Resource Server to an + * {@link OAuth2AuthorizationFailureHandler}. A + * {@link RemoveAuthorizedClientOAuth2AuthorizationFailureHandler} can be used to remove + * the cached {@link OAuth2AuthorizedClient}, so that future requests will result in a new + * token being retrieved from an Authorization Server, and sent to the Resource Server. + * + *

+ * Use either {@link #authorizationFailureHandler(OAuth2AuthorizedClientRepository)} or + * {@link #authorizationFailureHandler(OAuth2AuthorizedClientService)} to create a + * {@link RemoveAuthorizedClientOAuth2AuthorizationFailureHandler} which can be provided + * to {@link #setAuthorizationFailureHandler(OAuth2AuthorizationFailureHandler)}. + * + *

+ * For example: + * + *

+ * OAuth2AuthorizationFailureHandler authorizationFailureHandler =
+ *     OAuth2ClientHttpRequestInterceptor.authorizationFailureHandler(authorizedClientRepository);
+ * requestInterceptor.setAuthorizationFailureHandler(authorizationFailureHandler);
+ * 
+ * + * @author Steve Riesenberg + * @since 6.4 + * @see OAuth2AuthorizedClientManager + * @see OAuth2AuthorizedClientProvider + * @see OAuth2AuthorizedClient + * @see OAuth2AuthorizationFailureHandler + */ +public final class OAuth2ClientHttpRequestInterceptor implements ClientHttpRequestInterceptor { + + // @formatter:off + private static final Map OAUTH2_ERROR_CODES = Map.of( + HttpStatus.UNAUTHORIZED, OAuth2ErrorCodes.INVALID_TOKEN, + HttpStatus.FORBIDDEN, OAuth2ErrorCodes.INSUFFICIENT_SCOPE + ); + // @formatter:on + + private static final Authentication ANONYMOUS_AUTHENTICATION = new AnonymousAuthenticationToken("anonymous", + "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + + private final OAuth2AuthorizedClientManager authorizedClientManager; + + private final ClientRegistrationIdResolver clientRegistrationIdResolver; + + // @formatter:off + private OAuth2AuthorizationFailureHandler authorizationFailureHandler = + (clientRegistrationId, principal, attributes) -> { }; + // @formatter:on + + private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder + .getContextHolderStrategy(); + + /** + * Constructs a {@code OAuth2ClientHttpRequestInterceptor} using the provided + * parameters. + * @param authorizedClientManager the {@link OAuth2AuthorizedClientManager} which + * manages the authorized client(s) + */ + public OAuth2ClientHttpRequestInterceptor(OAuth2AuthorizedClientManager authorizedClientManager) { + this(authorizedClientManager, new RequestAttributeClientRegistrationIdResolver()); + } + + /** + * Constructs a {@code OAuth2ClientHttpRequestInterceptor} using the provided + * parameters. + * @param authorizedClientManager the {@link OAuth2AuthorizedClientManager} which + * manages the authorized client(s) + * @param clientRegistrationIdResolver the strategy for resolving a + * {@code clientRegistrationId} from the intercepted request + */ + public OAuth2ClientHttpRequestInterceptor(OAuth2AuthorizedClientManager authorizedClientManager, + ClientRegistrationIdResolver clientRegistrationIdResolver) { + Assert.notNull(authorizedClientManager, "authorizedClientManager cannot be null"); + Assert.notNull(clientRegistrationIdResolver, "clientRegistrationIdResolver cannot be null"); + this.authorizedClientManager = authorizedClientManager; + this.clientRegistrationIdResolver = clientRegistrationIdResolver; + } + + /** + * Sets the {@link OAuth2AuthorizationFailureHandler} that handles authentication and + * authorization failures when communicating to the OAuth 2.0 Resource Server. + * + *

+ * For example, a {@link RemoveAuthorizedClientOAuth2AuthorizationFailureHandler} is + * typically used to remove the cached {@link OAuth2AuthorizedClient}, so that the + * same token is no longer used in future requests to the Resource Server. + * @param authorizationFailureHandler the {@link OAuth2AuthorizationFailureHandler} + * that handles authentication and authorization failures + * @see #authorizationFailureHandler(OAuth2AuthorizedClientRepository) + * @see #authorizationFailureHandler(OAuth2AuthorizedClientService) + */ + public void setAuthorizationFailureHandler(OAuth2AuthorizationFailureHandler authorizationFailureHandler) { + Assert.notNull(authorizationFailureHandler, "authorizationFailureHandler cannot be null"); + this.authorizationFailureHandler = authorizationFailureHandler; + } + + /** + * Provides an {@link OAuth2AuthorizationFailureHandler} that handles authentication + * and authorization failures when communicating to the OAuth 2.0 Resource Server + * using a {@link OAuth2AuthorizedClientRepository}. + * + *

+ * When this method is used, authentication (HTTP 401) and authorization (HTTP 403) + * failures returned from an OAuth 2.0 Resource Server will be forwarded to a + * {@link RemoveAuthorizedClientOAuth2AuthorizationFailureHandler}, which will + * potentially remove the {@link OAuth2AuthorizedClient} from the given + * {@link OAuth2AuthorizedClientRepository}, depending on the OAuth 2.0 error code + * returned. Authentication failures returned from an OAuth 2.0 Resource Server + * typically indicate that the token is invalid, and should not be used in future + * requests. Removing the authorized client from the repository will ensure that the + * existing token will not be sent for future requests to the Resource Server, and a + * new token is retrieved from the Authorization Server and used for future requests + * to the Resource Server. + * @param authorizedClientRepository the repository of authorized clients + * @see #setAuthorizationFailureHandler(OAuth2AuthorizationFailureHandler) + */ + public static OAuth2AuthorizationFailureHandler authorizationFailureHandler( + OAuth2AuthorizedClientRepository authorizedClientRepository) { + Assert.notNull(authorizedClientRepository, "authorizedClientRepository cannot be null"); + return new RemoveAuthorizedClientOAuth2AuthorizationFailureHandler( + (clientRegistrationId, principal, attributes) -> { + HttpServletRequest request = (HttpServletRequest) attributes + .get(HttpServletRequest.class.getName()); + HttpServletResponse response = (HttpServletResponse) attributes + .get(HttpServletResponse.class.getName()); + authorizedClientRepository.removeAuthorizedClient(clientRegistrationId, principal, request, + response); + }); + } + + /** + * Provides an {@link OAuth2AuthorizationFailureHandler} that handles authentication + * and authorization failures when communicating to the OAuth 2.0 Resource Server + * using a {@link OAuth2AuthorizedClientService}. + * + *

+ * When this method is used, authentication (HTTP 401) and authorization (HTTP 403) + * failures returned from an OAuth 2.0 Resource Server will be forwarded to a + * {@link RemoveAuthorizedClientOAuth2AuthorizationFailureHandler}, which will + * potentially remove the {@link OAuth2AuthorizedClient} from the given + * {@link OAuth2AuthorizedClientService}, depending on the OAuth 2.0 error code + * returned. Authentication failures returned from an OAuth 2.0 Resource Server + * typically indicate that the token is invalid, and should not be used in future + * requests. Removing the authorized client from the repository will ensure that the + * existing token will not be sent for future requests to the Resource Server, and a + * new token is retrieved from the Authorization Server and used for future requests + * to the Resource Server. + * @param authorizedClientService the service used to manage authorized clients + * @see #setAuthorizationFailureHandler(OAuth2AuthorizationFailureHandler) + */ + public static OAuth2AuthorizationFailureHandler authorizationFailureHandler( + OAuth2AuthorizedClientService authorizedClientService) { + Assert.notNull(authorizedClientService, "authorizedClientService cannot be null"); + return new RemoveAuthorizedClientOAuth2AuthorizationFailureHandler( + (clientRegistrationId, principal, attributes) -> authorizedClientService + .removeAuthorizedClient(clientRegistrationId, principal.getName())); + } + + /** + * Sets the {@link SecurityContextHolderStrategy} to use. The default action is to use + * the {@link SecurityContextHolderStrategy} stored in {@link SecurityContextHolder}. + * @param securityContextHolderStrategy the {@link SecurityContextHolderStrategy} to + * use + */ + public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) { + Assert.notNull(securityContextHolderStrategy, "securityContextHolderStrategy cannot be null"); + this.securityContextHolderStrategy = securityContextHolderStrategy; + } + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) + throws IOException { + Authentication principal = this.securityContextHolderStrategy.getContext().getAuthentication(); + if (principal == null) { + principal = ANONYMOUS_AUTHENTICATION; + } + + authorizeClient(request, principal); + try { + ClientHttpResponse response = execution.execute(request, body); + handleAuthorizationFailure(request, principal, response.getHeaders(), response.getStatusCode()); + return response; + } + catch (RestClientResponseException ex) { + handleAuthorizationFailure(request, principal, ex.getResponseHeaders(), ex.getStatusCode()); + throw ex; + } + catch (OAuth2AuthorizationException ex) { + handleAuthorizationFailure(ex, principal); + throw ex; + } + } + + private void authorizeClient(HttpRequest request, Authentication principal) { + String clientRegistrationId = this.clientRegistrationIdResolver.resolve(request); + if (clientRegistrationId == null) { + return; + } + + OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId) + .principal(principal) + .build(); + OAuth2AuthorizedClient authorizedClient = this.authorizedClientManager.authorize(authorizeRequest); + if (authorizedClient != null) { + request.getHeaders().setBearerAuth(authorizedClient.getAccessToken().getTokenValue()); + } + } + + private void handleAuthorizationFailure(HttpRequest request, Authentication principal, HttpHeaders headers, + HttpStatusCode httpStatus) { + OAuth2Error error = resolveOAuth2ErrorIfPossible(headers, httpStatus); + if (error == null) { + return; + } + + String clientRegistrationId = this.clientRegistrationIdResolver.resolve(request); + if (clientRegistrationId == null) { + return; + } + + ClientAuthorizationException authorizationException = new ClientAuthorizationException(error, + clientRegistrationId); + handleAuthorizationFailure(authorizationException, principal); + } + + private static OAuth2Error resolveOAuth2ErrorIfPossible(HttpHeaders headers, HttpStatusCode httpStatus) { + String wwwAuthenticateHeader = headers.getFirst(HttpHeaders.WWW_AUTHENTICATE); + if (wwwAuthenticateHeader != null) { + Map parameters = parseWwwAuthenticateHeader(wwwAuthenticateHeader); + if (parameters.containsKey(OAuth2ParameterNames.ERROR)) { + return new OAuth2Error(parameters.get(OAuth2ParameterNames.ERROR), + parameters.get(OAuth2ParameterNames.ERROR_DESCRIPTION), + parameters.get(OAuth2ParameterNames.ERROR_URI)); + } + } + + String errorCode = OAUTH2_ERROR_CODES.get(httpStatus); + if (errorCode != null) { + return new OAuth2Error(errorCode, null, "https://tools.ietf.org/html/rfc6750#section-3.1"); + } + + return null; + } + + private static Map parseWwwAuthenticateHeader(String wwwAuthenticateHeader) { + if (!StringUtils.hasLength(wwwAuthenticateHeader) + || !StringUtils.startsWithIgnoreCase(wwwAuthenticateHeader, "bearer")) { + return Map.of(); + } + + String headerValue = wwwAuthenticateHeader.substring("bearer".length()).stripLeading(); + Map parameters = new HashMap<>(); + for (String kvPair : StringUtils.delimitedListToStringArray(headerValue, ",")) { + String[] kv = StringUtils.split(kvPair, "="); + if (kv == null || kv.length <= 1) { + continue; + } + + parameters.put(kv[0].trim(), kv[1].trim().replace("\"", "")); + } + + return parameters; + } + + private void handleAuthorizationFailure(OAuth2AuthorizationException authorizationException, + Authentication principal) { + ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder + .getRequestAttributes(); + Map attributes = new HashMap<>(); + if (requestAttributes != null) { + attributes.put(HttpServletRequest.class.getName(), requestAttributes.getRequest()); + if (requestAttributes.getResponse() != null) { + attributes.put(HttpServletResponse.class.getName(), requestAttributes.getResponse()); + } + } + + this.authorizationFailureHandler.onAuthorizationFailure(authorizationException, principal, attributes); + } + + /** + * A strategy for resolving a {@code clientRegistrationId} from an intercepted + * request. + */ + @FunctionalInterface + public interface ClientRegistrationIdResolver { + + /** + * Resolve the {@code clientRegistrationId} from the current request, which is + * used to obtain an {@link OAuth2AuthorizedClient}. + * @param request the intercepted request, containing HTTP method, URI, headers, + * and request attributes + * @return the {@code clientRegistrationId} to be used for resolving an + * {@link OAuth2AuthorizedClient}. + */ + @Nullable + String resolve(HttpRequest request); + + } + +} 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 new file mode 100644 index 0000000000..9e8f7b51a0 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/client/RequestAttributeClientRegistrationIdResolver.java @@ -0,0 +1,60 @@ +/* + * Copyright 2002-2024 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.util.Map; +import java.util.function.Consumer; + +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; + +/** + * A strategy for resolving a {@code clientRegistrationId} from an intercepted request + * using {@link ClientHttpRequest#getAttributes() attributes}. + * + * @author Steve Riesenberg + * @see OAuth2ClientHttpRequestInterceptor + */ +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); + } + + /** + * Modifies the {@link ClientHttpRequest#getAttributes() attributes} to include 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); + } + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/function/client/OAuth2ClientHttpRequestInterceptorTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/function/client/OAuth2ClientHttpRequestInterceptorTests.java new file mode 100644 index 0000000000..d7f98cb105 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/function/client/OAuth2ClientHttpRequestInterceptorTests.java @@ -0,0 +1,725 @@ +/* + * Copyright 2002-2024 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.function.client; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.http.HttpHeaders; +import org.springframework.http.HttpRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.oauth2.client.ClientAuthorizationException; +import org.springframework.security.oauth2.client.OAuth2AuthorizationFailureHandler; +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor; +import org.springframework.security.oauth2.client.web.client.RequestAttributeClientRegistrationIdResolver; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.TestOAuth2AccessTokens; +import org.springframework.security.oauth2.core.oidc.StandardClaimNames; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.RequestMatcher; +import org.springframework.test.web.client.ResponseCreator; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestClient; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.headerDoesNotExist; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * Tests for {@link OAuth2ClientHttpRequestInterceptor}. + * + * @author Steve Riesenberg + */ +@ExtendWith(MockitoExtension.class) +public class OAuth2ClientHttpRequestInterceptorTests { + + private static final String REQUEST_URI = "/resources"; + + private static final String ERROR_DESCRIPTION = "The request requires higher privileges than provided by the access token."; + + private static final String ERROR_URI = "https://tools.ietf.org/html/rfc6750#section-3.1"; + + @Mock + private OAuth2AuthorizedClientManager authorizedClientManager; + + @Mock + private OAuth2AuthorizationFailureHandler authorizationFailureHandler; + + @Mock + private OAuth2AuthorizedClientRepository authorizedClientRepository; + + @Mock + private SecurityContextHolderStrategy securityContextHolderStrategy; + + @Mock + private OAuth2AuthorizedClientService authorizedClientService; + + @Mock + private OAuth2ClientHttpRequestInterceptor.ClientRegistrationIdResolver clientRegistrationIdResolver; + + @Captor + private ArgumentCaptor authorizeRequestCaptor; + + @Captor + private ArgumentCaptor authorizationExceptionCaptor; + + @Captor + private ArgumentCaptor authenticationCaptor; + + @Captor + private ArgumentCaptor> attributesCaptor; + + private ClientRegistration clientRegistration; + + private OAuth2AuthorizedClient authorizedClient; + + private OAuth2AuthenticationToken principal; + + private OAuth2ClientHttpRequestInterceptor requestInterceptor; + + private MockRestServiceServer server; + + private RestClient restClient; + + @BeforeEach + public void setUp() { + this.clientRegistration = TestClientRegistrations.clientRegistration().build(); + OAuth2AccessToken accessToken = TestOAuth2AccessTokens.scopes("read", "write"); + this.authorizedClient = new OAuth2AuthorizedClient(this.clientRegistration, "user", accessToken); + List authorities = AuthorityUtils.createAuthorityList("OAUTH2_USER"); + Map attributes = Map.of(StandardClaimNames.SUB, "user"); + OAuth2User user = new DefaultOAuth2User(authorities, attributes, StandardClaimNames.SUB); + this.principal = new OAuth2AuthenticationToken(user, authorities, "login-client"); + this.requestInterceptor = new OAuth2ClientHttpRequestInterceptor(this.authorizedClientManager); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + RequestContextHolder.resetRequestAttributes(); + } + + @Test + public void constructorWhenAuthorizedClientManagerIsNullThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> new OAuth2ClientHttpRequestInterceptor(null)) + .withMessage("authorizedClientManager cannot be null"); + } + + @Test + public void constructorWhenClientRegistrationIdResolverIsNullThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2ClientHttpRequestInterceptor(this.authorizedClientManager, null)) + .withMessage("clientRegistrationIdResolver cannot be null"); + } + + @Test + public void setAuthorizationFailureHandlerWhenNullThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.requestInterceptor.setAuthorizationFailureHandler(null)) + .withMessage("authorizationFailureHandler cannot be null"); + } + + @Test + public void authorizationFailureHandlerWhenAuthorizedClientRepositoryIsNullThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> OAuth2ClientHttpRequestInterceptor + .authorizationFailureHandler((OAuth2AuthorizedClientRepository) null)) + .withMessage("authorizedClientRepository cannot be null"); + } + + @Test + public void authorizationFailureHandlerWhenAuthorizedClientServiceIsNullThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> OAuth2ClientHttpRequestInterceptor + .authorizationFailureHandler((OAuth2AuthorizedClientService) null)) + .withMessage("authorizedClientService cannot be null"); + } + + @Test + public void setSecurityContextHolderStrategyWhenNullThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> this.requestInterceptor.setSecurityContextHolderStrategy(null)) + .withMessage("securityContextHolderStrategy cannot be null"); + } + + @Test + public void interceptWhenAnonymousThenAuthorizationHeaderNotSet() { + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(headerDoesNotExist(HttpHeaders.AUTHORIZATION)) + .andRespond(withApplicationJson()); + performRequest(withDefaults()); + this.server.verify(); + verifyNoInteractions(this.authorizedClientManager, this.authorizationFailureHandler); + } + + @Test + public void interceptWhenAnonymousAndAuthorizedThenAuthorizationHeaderSet() { + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .willReturn(this.authorizedClient); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(hasAuthorizationHeader(this.authorizedClient.getAccessToken())) + .andRespond(withApplicationJson()); + performRequest(withClientRegistrationId()); + this.server.verify(); + verify(this.authorizedClientManager).authorize(this.authorizeRequestCaptor.capture()); + verifyNoMoreInteractions(this.authorizedClientManager); + verifyNoInteractions(this.authorizationFailureHandler); + OAuth2AuthorizeRequest authorizeRequest = this.authorizeRequestCaptor.getValue(); + assertThat(authorizeRequest.getClientRegistrationId()).isEqualTo(this.clientRegistration.getRegistrationId()); + assertThat(authorizeRequest.getPrincipal()).isInstanceOf(AnonymousAuthenticationToken.class); + } + + @Test + public void interceptWhenAnonymousAndNotAuthorizedThenAuthorizationHeaderNotSet() { + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))).willReturn(null); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(headerDoesNotExist(HttpHeaders.AUTHORIZATION)) + .andRespond(withApplicationJson()); + performRequest(withClientRegistrationId()); + this.server.verify(); + verify(this.authorizedClientManager).authorize(this.authorizeRequestCaptor.capture()); + verifyNoMoreInteractions(this.authorizedClientManager); + verifyNoInteractions(this.authorizationFailureHandler); + OAuth2AuthorizeRequest authorizeRequest = this.authorizeRequestCaptor.getValue(); + assertThat(authorizeRequest.getClientRegistrationId()).isEqualTo(this.clientRegistration.getRegistrationId()); + assertThat(authorizeRequest.getPrincipal()).isInstanceOf(AnonymousAuthenticationToken.class); + } + + @Test + public void interceptWhenAuthenticatedAndAuthorizedThenAuthorizationHeaderSet() { + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .willReturn(this.authorizedClient); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(hasAuthorizationHeader(this.authorizedClient.getAccessToken())) + .andRespond(withApplicationJson()); + SecurityContext securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(this.principal); + SecurityContextHolder.setContext(securityContext); + performRequest(withClientRegistrationId()); + this.server.verify(); + verify(this.authorizedClientManager).authorize(this.authorizeRequestCaptor.capture()); + verifyNoMoreInteractions(this.authorizedClientManager); + verifyNoInteractions(this.authorizationFailureHandler); + OAuth2AuthorizeRequest authorizeRequest = this.authorizeRequestCaptor.getValue(); + assertThat(authorizeRequest.getClientRegistrationId()).isEqualTo(this.clientRegistration.getRegistrationId()); + assertThat(authorizeRequest.getPrincipal()).isEqualTo(this.principal); + } + + @Test + public void interceptWhenAuthenticatedAndNotAuthorizedThenAuthorizationHeaderNotSet() { + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))).willReturn(null); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(headerDoesNotExist(HttpHeaders.AUTHORIZATION)) + .andRespond(withApplicationJson()); + SecurityContext securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(this.principal); + SecurityContextHolder.setContext(securityContext); + performRequest(withClientRegistrationId()); + this.server.verify(); + verify(this.authorizedClientManager).authorize(this.authorizeRequestCaptor.capture()); + verifyNoMoreInteractions(this.authorizedClientManager); + verifyNoInteractions(this.authorizationFailureHandler); + OAuth2AuthorizeRequest authorizeRequest = this.authorizeRequestCaptor.getValue(); + assertThat(authorizeRequest.getClientRegistrationId()).isEqualTo(this.clientRegistration.getRegistrationId()); + assertThat(authorizeRequest.getPrincipal()).isInstanceOf(OAuth2AuthenticationToken.class); + } + + @Test + public void interceptWhenAnonymousAndUnauthorizedThenDoesNotCallAuthorizationFailureHandler() { + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(headerDoesNotExist(HttpHeaders.AUTHORIZATION)) + .andRespond(withWwwAuthenticateHeader(HttpStatus.UNAUTHORIZED)); + assertThatExceptionOfType(HttpClientErrorException.class).isThrownBy(() -> performRequest(withDefaults())) + .satisfies((ex) -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + this.server.verify(); + verifyNoInteractions(this.authorizedClientManager, this.authorizationFailureHandler); + } + + @Test + public void interceptWhenAnonymousAndOAuth2ErrorInWwwAuthenticateHeaderThenCallsAuthorizationFailureHandlerWithInsufficientScopeError() { + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .willReturn(this.authorizedClient); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(hasAuthorizationHeader(this.authorizedClient.getAccessToken())) + .andRespond(withWwwAuthenticateHeader(HttpStatus.OK)); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)); + performRequest(withClientRegistrationId()); + this.server.verify(); + verify(this.authorizedClientManager).authorize(any(OAuth2AuthorizeRequest.class)); + verify(this.authorizationFailureHandler).onAuthorizationFailure(this.authorizationExceptionCaptor.capture(), + this.authenticationCaptor.capture(), this.attributesCaptor.capture()); + verifyNoMoreInteractions(this.authorizedClientManager, this.authorizationFailureHandler); + assertThat(this.authorizationExceptionCaptor.getValue()).isInstanceOfSatisfying( + ClientAuthorizationException.class, + hasOAuth2Error(OAuth2ErrorCodes.INSUFFICIENT_SCOPE, ERROR_DESCRIPTION)); + assertThat(this.authenticationCaptor.getValue()).isInstanceOf(AnonymousAuthenticationToken.class); + assertThat(this.attributesCaptor.getValue()).containsExactly(entry(HttpServletRequest.class.getName(), request), + entry(HttpServletResponse.class.getName(), response)); + } + + @Test + public void interceptWhenAuthenticatedAndOAuth2ErrorInWwwAuthenticateHeaderThenCallsAuthorizationFailureHandlerWithInsufficientScopeError() { + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .willReturn(this.authorizedClient); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(hasAuthorizationHeader(this.authorizedClient.getAccessToken())) + .andRespond(withWwwAuthenticateHeader(HttpStatus.OK)); + SecurityContext securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(this.principal); + SecurityContextHolder.setContext(securityContext); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)); + performRequest(withClientRegistrationId()); + this.server.verify(); + verify(this.authorizedClientManager).authorize(any(OAuth2AuthorizeRequest.class)); + verify(this.authorizationFailureHandler).onAuthorizationFailure(this.authorizationExceptionCaptor.capture(), + this.authenticationCaptor.capture(), this.attributesCaptor.capture()); + verifyNoMoreInteractions(this.authorizedClientManager, this.authorizationFailureHandler); + assertThat(this.authorizationExceptionCaptor.getValue()).isInstanceOfSatisfying( + ClientAuthorizationException.class, + hasOAuth2Error(OAuth2ErrorCodes.INSUFFICIENT_SCOPE, ERROR_DESCRIPTION)); + assertThat(this.authenticationCaptor.getValue()).isEqualTo(this.principal); + assertThat(this.attributesCaptor.getValue()).containsExactly(entry(HttpServletRequest.class.getName(), request), + entry(HttpServletResponse.class.getName(), response)); + } + + @Test + public void interceptWhenUnauthorizedAndOAuth2ErrorInWwwAuthenticateHeaderThenCallsAuthorizationFailureHandlerWithInsufficientScopeError() { + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .willReturn(this.authorizedClient); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(hasAuthorizationHeader(this.authorizedClient.getAccessToken())) + .andRespond(withWwwAuthenticateHeader(HttpStatus.UNAUTHORIZED)); + SecurityContext securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(this.principal); + SecurityContextHolder.setContext(securityContext); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)); + assertThatExceptionOfType(HttpClientErrorException.class) + .isThrownBy(() -> performRequest(withClientRegistrationId())) + .satisfies((ex) -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + this.server.verify(); + verify(this.authorizedClientManager).authorize(any(OAuth2AuthorizeRequest.class)); + verify(this.authorizationFailureHandler).onAuthorizationFailure(this.authorizationExceptionCaptor.capture(), + this.authenticationCaptor.capture(), this.attributesCaptor.capture()); + verifyNoMoreInteractions(this.authorizedClientManager, this.authorizationFailureHandler); + assertThat(this.authorizationExceptionCaptor.getValue()).isInstanceOfSatisfying( + ClientAuthorizationException.class, + hasOAuth2Error(OAuth2ErrorCodes.INSUFFICIENT_SCOPE, ERROR_DESCRIPTION)); + assertThat(this.authenticationCaptor.getValue()).isEqualTo(this.principal); + assertThat(this.attributesCaptor.getValue()).containsExactly(entry(HttpServletRequest.class.getName(), request), + entry(HttpServletResponse.class.getName(), response)); + } + + @Test + public void interceptWhenForbiddenAndOAuth2ErrorInWwwAuthenticateHeaderThenCallsAuthorizationFailureHandlerWithInsufficientScopeError() { + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .willReturn(this.authorizedClient); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(hasAuthorizationHeader(this.authorizedClient.getAccessToken())) + .andRespond(withWwwAuthenticateHeader(HttpStatus.FORBIDDEN)); + SecurityContext securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(this.principal); + SecurityContextHolder.setContext(securityContext); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)); + assertThatExceptionOfType(HttpClientErrorException.class) + .isThrownBy(() -> performRequest(withClientRegistrationId())) + .satisfies((ex) -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN)); + this.server.verify(); + verify(this.authorizedClientManager).authorize(any(OAuth2AuthorizeRequest.class)); + verify(this.authorizationFailureHandler).onAuthorizationFailure(this.authorizationExceptionCaptor.capture(), + this.authenticationCaptor.capture(), this.attributesCaptor.capture()); + verifyNoMoreInteractions(this.authorizedClientManager, this.authorizationFailureHandler); + assertThat(this.authorizationExceptionCaptor.getValue()).isInstanceOfSatisfying( + ClientAuthorizationException.class, + hasOAuth2Error(OAuth2ErrorCodes.INSUFFICIENT_SCOPE, ERROR_DESCRIPTION)); + assertThat(this.authenticationCaptor.getValue()).isEqualTo(this.principal); + assertThat(this.attributesCaptor.getValue()).containsExactly(entry(HttpServletRequest.class.getName(), request), + entry(HttpServletResponse.class.getName(), response)); + } + + @Test + public void interceptWhenUnauthorizedThenCallsAuthorizationFailureHandlerWithInvalidTokenError() { + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .willReturn(this.authorizedClient); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(hasAuthorizationHeader(this.authorizedClient.getAccessToken())) + .andRespond(withStatus(HttpStatus.UNAUTHORIZED)); + SecurityContext securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(this.principal); + SecurityContextHolder.setContext(securityContext); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)); + assertThatExceptionOfType(HttpClientErrorException.class) + .isThrownBy(() -> performRequest(withClientRegistrationId())) + .satisfies((ex) -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + this.server.verify(); + verify(this.authorizedClientManager).authorize(any(OAuth2AuthorizeRequest.class)); + verify(this.authorizationFailureHandler).onAuthorizationFailure(this.authorizationExceptionCaptor.capture(), + this.authenticationCaptor.capture(), this.attributesCaptor.capture()); + verifyNoMoreInteractions(this.authorizedClientManager, this.authorizationFailureHandler); + assertThat(this.authorizationExceptionCaptor.getValue()).isInstanceOfSatisfying( + ClientAuthorizationException.class, hasOAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, null)); + assertThat(this.authenticationCaptor.getValue()).isEqualTo(this.principal); + assertThat(this.attributesCaptor.getValue()).containsExactly(entry(HttpServletRequest.class.getName(), request), + entry(HttpServletResponse.class.getName(), response)); + } + + @Test + public void interceptWhenForbiddenThenCallsAuthorizationFailureHandlerWithInsufficientScopeError() { + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .willReturn(this.authorizedClient); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(hasAuthorizationHeader(this.authorizedClient.getAccessToken())) + .andRespond(withStatus(HttpStatus.FORBIDDEN)); + SecurityContext securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(this.principal); + SecurityContextHolder.setContext(securityContext); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)); + assertThatExceptionOfType(HttpClientErrorException.class) + .isThrownBy(() -> performRequest(withClientRegistrationId())) + .satisfies((ex) -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN)); + this.server.verify(); + verify(this.authorizedClientManager).authorize(any(OAuth2AuthorizeRequest.class)); + verify(this.authorizationFailureHandler).onAuthorizationFailure(this.authorizationExceptionCaptor.capture(), + this.authenticationCaptor.capture(), this.attributesCaptor.capture()); + verifyNoMoreInteractions(this.authorizedClientManager, this.authorizationFailureHandler); + assertThat(this.authorizationExceptionCaptor.getValue()).isInstanceOfSatisfying( + ClientAuthorizationException.class, hasOAuth2Error(OAuth2ErrorCodes.INSUFFICIENT_SCOPE, null)); + assertThat(this.authenticationCaptor.getValue()).isEqualTo(this.principal); + assertThat(this.attributesCaptor.getValue()).containsExactly(entry(HttpServletRequest.class.getName(), request), + entry(HttpServletResponse.class.getName(), response)); + } + + @Test + public void interceptWhenInternalServerErrorThenDoesNotCallAuthorizationFailureHandler() { + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .willReturn(this.authorizedClient); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(hasAuthorizationHeader(this.authorizedClient.getAccessToken())) + .andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)); + assertThatExceptionOfType(HttpServerErrorException.class) + .isThrownBy(() -> performRequest(withClientRegistrationId())) + .satisfies((ex) -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)); + this.server.verify(); + verify(this.authorizedClientManager).authorize(any(OAuth2AuthorizeRequest.class)); + verifyNoMoreInteractions(this.authorizedClientManager); + verifyNoInteractions(this.authorizationFailureHandler); + } + + @Test + public void interceptWhenAuthorizationExceptionThenCallsAuthorizationFailureHandlerWithException() { + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .willReturn(this.authorizedClient); + + bindToRestClient(withRequestInterceptor()); + OAuth2AuthorizationException authorizationException = new OAuth2AuthorizationException( + new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN)); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(hasAuthorizationHeader(this.authorizedClient.getAccessToken())) + .andRespond(withException(authorizationException)); + SecurityContext securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(this.principal); + SecurityContextHolder.setContext(securityContext); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)); + assertThatExceptionOfType(OAuth2AuthorizationException.class) + .isThrownBy(() -> performRequest(withClientRegistrationId())) + .isEqualTo(authorizationException); + this.server.verify(); + verify(this.authorizedClientManager).authorize(any(OAuth2AuthorizeRequest.class)); + verify(this.authorizationFailureHandler).onAuthorizationFailure(this.authorizationExceptionCaptor.capture(), + this.authenticationCaptor.capture(), this.attributesCaptor.capture()); + verifyNoMoreInteractions(this.authorizedClientManager, this.authorizationFailureHandler); + assertThat(this.authorizationExceptionCaptor.getValue()).isEqualTo(authorizationException); + assertThat(this.authenticationCaptor.getValue()).isEqualTo(this.principal); + assertThat(this.attributesCaptor.getValue()).containsExactly(entry(HttpServletRequest.class.getName(), request), + entry(HttpServletResponse.class.getName(), response)); + } + + @Test + public void interceptWhenUnauthorizedAndAuthorizationFailureHandlerSetWithAuthorizedClientRepositoryThenAuthorizedClientRemoved() { + this.requestInterceptor.setAuthorizationFailureHandler( + OAuth2ClientHttpRequestInterceptor.authorizationFailureHandler(this.authorizedClientRepository)); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .willReturn(this.authorizedClient); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(hasAuthorizationHeader(this.authorizedClient.getAccessToken())) + .andRespond(withStatus(HttpStatus.UNAUTHORIZED)); + SecurityContext securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(this.principal); + SecurityContextHolder.setContext(securityContext); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)); + assertThatExceptionOfType(HttpClientErrorException.class) + .isThrownBy(() -> performRequest(withClientRegistrationId())) + .satisfies((ex) -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + this.server.verify(); + verify(this.authorizedClientManager).authorize(any(OAuth2AuthorizeRequest.class)); + verify(this.authorizedClientRepository).removeAuthorizedClient(this.clientRegistration.getRegistrationId(), + this.principal, request, response); + verifyNoMoreInteractions(this.authorizedClientManager, this.authorizedClientRepository); + } + + @Test + public void interceptWhenUnauthorizedAndAuthorizationFailureHandlerSetWithAuthorizedClientServiceThenAuthorizedClientRemoved() { + this.requestInterceptor.setAuthorizationFailureHandler( + OAuth2ClientHttpRequestInterceptor.authorizationFailureHandler(this.authorizedClientService)); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .willReturn(this.authorizedClient); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(hasAuthorizationHeader(this.authorizedClient.getAccessToken())) + .andRespond(withStatus(HttpStatus.UNAUTHORIZED)); + SecurityContext securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(this.principal); + SecurityContextHolder.setContext(securityContext); + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)); + assertThatExceptionOfType(HttpClientErrorException.class) + .isThrownBy(() -> performRequest(withClientRegistrationId())) + .satisfies((ex) -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED)); + this.server.verify(); + verify(this.authorizedClientManager).authorize(any(OAuth2AuthorizeRequest.class)); + verify(this.authorizedClientService).removeAuthorizedClient(this.clientRegistration.getRegistrationId(), + this.principal.getName()); + verifyNoMoreInteractions(this.authorizedClientManager, this.authorizedClientService); + } + + @Test + public void interceptWhenClientRegistrationIdResolverSetThenUsed() { + this.requestInterceptor = new OAuth2ClientHttpRequestInterceptor(this.authorizedClientManager, + this.clientRegistrationIdResolver); + this.requestInterceptor.setAuthorizationFailureHandler(this.authorizationFailureHandler); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .willReturn(this.authorizedClient); + + String clientRegistrationId = "test-client"; + given(this.clientRegistrationIdResolver.resolve(any(HttpRequest.class))).willReturn(clientRegistrationId); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(hasAuthorizationHeader(this.authorizedClient.getAccessToken())) + .andRespond(withApplicationJson()); + SecurityContext securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(this.principal); + SecurityContextHolder.setContext(securityContext); + performRequest(withDefaults()); + this.server.verify(); + verify(this.authorizedClientManager).authorize(this.authorizeRequestCaptor.capture()); + verify(this.clientRegistrationIdResolver).resolve(any(HttpRequest.class)); + verifyNoMoreInteractions(this.clientRegistrationIdResolver, this.authorizedClientManager); + verifyNoInteractions(this.authorizationFailureHandler); + OAuth2AuthorizeRequest authorizeRequest = this.authorizeRequestCaptor.getValue(); + assertThat(authorizeRequest.getClientRegistrationId()).isEqualTo(clientRegistrationId); + assertThat(authorizeRequest.getPrincipal()).isEqualTo(this.principal); + } + + @Test + public void interceptWhenCustomSecurityContextHolderStrategySetThenUsed() { + this.requestInterceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy); + given(this.authorizedClientManager.authorize(any(OAuth2AuthorizeRequest.class))) + .willReturn(this.authorizedClient); + + bindToRestClient(withRequestInterceptor()); + this.server.expect(requestTo(REQUEST_URI)) + .andExpect(hasAuthorizationHeader(this.authorizedClient.getAccessToken())) + .andRespond(withApplicationJson()); + SecurityContext securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(this.principal); + given(this.securityContextHolderStrategy.getContext()).willReturn(securityContext); + performRequest(withClientRegistrationId()); + this.server.verify(); + verify(this.authorizedClientManager).authorize(this.authorizeRequestCaptor.capture()); + verify(this.securityContextHolderStrategy).getContext(); + verifyNoMoreInteractions(this.authorizedClientManager); + OAuth2AuthorizeRequest authorizeRequest = this.authorizeRequestCaptor.getValue(); + assertThat(authorizeRequest.getClientRegistrationId()).isEqualTo(this.clientRegistration.getRegistrationId()); + assertThat(authorizeRequest.getPrincipal()).isEqualTo(this.principal); + } + + private void bindToRestClient(Consumer customizer) { + RestClient.Builder builder = RestClient.builder(); + customizer.accept(builder); + this.server = MockRestServiceServer.bindTo(builder).build(); + this.restClient = builder.build(); + } + + private Consumer withRequestInterceptor() { + return (builder) -> builder.requestInterceptor(this.requestInterceptor); + } + + private static RequestMatcher hasAuthorizationHeader(OAuth2AccessToken accessToken) { + String tokenType = accessToken.getTokenType().getValue(); + String tokenValue = accessToken.getTokenValue(); + return header(HttpHeaders.AUTHORIZATION, "%s %s".formatted(tokenType, tokenValue)); + } + + private static ResponseCreator withApplicationJson() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return withSuccess().headers(headers).body("{}"); + } + + private static ResponseCreator withWwwAuthenticateHeader(HttpStatus httpStatus) { + String wwwAuthenticateHeader = "Bearer error=\"insufficient_scope\", " + + "error_description=\"The request requires higher privileges than provided by the access token.\", " + + "error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\""; + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.WWW_AUTHENTICATE, wwwAuthenticateHeader); + return withStatus(httpStatus).headers(headers); + } + + private static ResponseCreator withException(OAuth2AuthorizationException ex) { + return (request) -> { + throw ex; + }; + } + + private void performRequest(Consumer> customizer) { + RestClient.RequestHeadersSpec spec = this.restClient.get().uri(REQUEST_URI); + customizer.accept(spec); + spec.retrieve().toBodilessEntity(); + } + + private static Consumer> withDefaults() { + return (spec) -> { + }; + } + + private Consumer> withClientRegistrationId() { + return (spec) -> spec.attributes(RequestAttributeClientRegistrationIdResolver + .clientRegistrationId(this.clientRegistration.getRegistrationId())); + } + + private Consumer hasOAuth2Error(String errorCode, String errorDescription) { + return (ex) -> { + assertThat(ex.getClientRegistrationId()).isEqualTo(this.clientRegistration.getRegistrationId()); + assertThat(ex.getError().getErrorCode()).isEqualTo(errorCode); + assertThat(ex.getError().getDescription()).isEqualTo(errorDescription); + assertThat(ex.getError().getUri()).isEqualTo(ERROR_URI); + assertThat(ex).hasNoCause(); + assertThat(ex).hasMessageContaining(errorCode); + }; + } + +}