Add RestClient interceptor

Closes gh-13588
This commit is contained in:
Steve Riesenberg 2024-04-30 17:48:17 -05:00
parent b294816600
commit e3c19ba86c
4 changed files with 1432 additions and 71 deletions

View File

@ -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:
* <<oauth2-client-log-users-in,I want to log users in using OAuth 2.0 or OpenID Connect 1.0>>
* <<oauth2-client-access-protected-resources,I want to obtain an access token for users in order to access a third-party API>>
* <<oauth2-client-access-protected-resources-current-user,I want to do both>> (log users in _and_ access a third-party API)
* <<oauth2-client-enable-extension-grant-type,I want to enable an extension grant type>>
* <<oauth2-client-customize-existing-grant-type,I want to customize an existing grant type>>
* <<oauth2-client-customize-request-parameters,I want to customize token request parameters>>
* <<oauth2-client-customize-rest-operations,I want to customize the `RestOperations` used by OAuth2 Client components>>
* I want to <<oauth2-client-log-users-in,log users in using OAuth 2.0 or OpenID Connect 1.0>>
* I want to <<oauth2-client-access-protected-resources,use `RestClient` to obtain an access token for users in order to access a third-party API>>
* I want to <<oauth2-client-access-protected-resources-current-user,do both>> (log users in _and_ access a third-party API)
* I want to <<oauth2-client-access-protected-resources-webclient,use `WebClient` to obtain an access token for users in order to access a third-party API>>
* I want to <<oauth2-client-enable-extension-grant-type,enable an extension grant type>>
* I want to <<oauth2-client-customize-existing-grant-type,customize an existing grant type>>
* I want to <<oauth2-client-customize-request-parameters,customize token request parameters>>
* I want to <<oauth2-client-customize-rest-operations,customize the `RestOperations` used by OAuth2 Client components>>
[[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"]
----
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor.netty</groupId>
<artifactId>reactor-netty</artifactId>
</dependency>
----
======
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<List<Message>> 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<List<Message>> {
return webClient.get()
val messages = restClient.get()
.uri("http://localhost:8090/messages")
.attributes(clientRegistrationId("my-oauth2-client"))
.retrieve()
.toEntityList<Message>()
.block()!!
.body<Array<Message>>()!!
.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<List<Message>> 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<List<Message>> {
val messages = restClient.get()
.uri("http://localhost:8090/messages")
.retrieve()
.body<Array<Message>>()!!
.toList()
return ResponseEntity.ok(messages)
}
data class Message(val message: String)
}
----
=====
[NOTE]
====
Unlike the <<oauth2-client-accessing-protected-resources-example,previous example>>, 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 <<oauth2-client-access-protected-resources-current-user,previous section>> 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<List<Message>> 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<List<Message>> {
return webClient.get()
.uri("http://localhost:8090/messages")
.attributes(clientRegistrationId("my-oauth2-client"))
.retrieve()
.toEntityList<Message>()
.block()!!
@ -943,12 +1144,6 @@ class MessagesController(private val webClient: WebClient) {
----
=====
[NOTE]
====
Unlike the <<oauth2-client-accessing-protected-resources-example,previous example>>, 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

View File

@ -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.
*
* <p>
* Example usage:
*
* <pre>
* OAuth2ClientHttpRequestInterceptor requestInterceptor =
* new OAuth2ClientHttpRequestInterceptor(authorizedClientManager);
* RestClient restClient = RestClient.builder()
* .requestInterceptor(requestInterceptor)
* .build();
* String response = restClient.get()
* .uri(uri)
* .retrieve()
* .body(String.class);
* </pre>
*
* <h3>Authentication and Authorization Failures</h3>
*
* <p>
* 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.
*
* <p>
* Use either {@link #authorizationFailureHandler(OAuth2AuthorizedClientRepository)} or
* {@link #authorizationFailureHandler(OAuth2AuthorizedClientService)} to create a
* {@link RemoveAuthorizedClientOAuth2AuthorizationFailureHandler} which can be provided
* to {@link #setAuthorizationFailureHandler(OAuth2AuthorizationFailureHandler)}.
*
* <p>
* For example:
*
* <pre>
* OAuth2AuthorizationFailureHandler authorizationFailureHandler =
* OAuth2ClientHttpRequestInterceptor.authorizationFailureHandler(authorizedClientRepository);
* requestInterceptor.setAuthorizationFailureHandler(authorizationFailureHandler);
* </pre>
*
* @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<HttpStatusCode, String> 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.
*
* <p>
* 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}.
*
* <p>
* 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}.
*
* <p>
* 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<String, String> 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<String, String> parseWwwAuthenticateHeader(String wwwAuthenticateHeader) {
if (!StringUtils.hasLength(wwwAuthenticateHeader)
|| !StringUtils.startsWithIgnoreCase(wwwAuthenticateHeader, "bearer")) {
return Map.of();
}
String headerValue = wwwAuthenticateHeader.substring("bearer".length()).stripLeading();
Map<String, String> 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<String, Object> 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);
}
}

View File

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

View File

@ -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<OAuth2AuthorizeRequest> authorizeRequestCaptor;
@Captor
private ArgumentCaptor<OAuth2AuthorizationException> authorizationExceptionCaptor;
@Captor
private ArgumentCaptor<Authentication> authenticationCaptor;
@Captor
private ArgumentCaptor<Map<String, Object>> 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<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("OAUTH2_USER");
Map<String, Object> 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<RestClient.Builder> customizer) {
RestClient.Builder builder = RestClient.builder();
customizer.accept(builder);
this.server = MockRestServiceServer.bindTo(builder).build();
this.restClient = builder.build();
}
private Consumer<RestClient.Builder> 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<RestClient.RequestHeadersSpec<?>> customizer) {
RestClient.RequestHeadersSpec<?> spec = this.restClient.get().uri(REQUEST_URI);
customizer.accept(spec);
spec.retrieve().toBodilessEntity();
}
private static Consumer<RestClient.RequestHeadersSpec<?>> withDefaults() {
return (spec) -> {
};
}
private Consumer<RestClient.RequestHeadersSpec<?>> withClientRegistrationId() {
return (spec) -> spec.attributes(RequestAttributeClientRegistrationIdResolver
.clientRegistrationId(this.clientRegistration.getRegistrationId()));
}
private Consumer<ClientAuthorizationException> 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);
};
}
}