Skip to content

Commit f2da2c5

Browse files
committed
Resolve OAuth2Error from WWW-Authenticate header
Issue gh-7699
1 parent 69156b7 commit f2da2c5

File tree

2 files changed

+123
-38
lines changed

2 files changed

+123
-38
lines changed

oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServerOAuth2AuthorizedClientExchangeFilterFunction.java

Lines changed: 72 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616

1717
package org.springframework.security.oauth2.client.web.reactive.function.client;
1818

19+
import org.springframework.http.HttpHeaders;
1920
import org.springframework.http.HttpStatus;
20-
import org.springframework.lang.Nullable;
2121
import org.springframework.security.authentication.AnonymousAuthenticationToken;
2222
import org.springframework.security.core.Authentication;
2323
import org.springframework.security.core.authority.AuthorityUtils;
@@ -34,20 +34,22 @@
3434
import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProvider;
3535
import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProviderBuilder;
3636
import org.springframework.security.oauth2.client.RefreshTokenReactiveOAuth2AuthorizedClientProvider;
37-
import org.springframework.security.oauth2.client.web.RemoveAuthorizedClientReactiveOAuth2AuthorizationFailureHandler;
38-
import org.springframework.security.oauth2.client.web.SaveAuthorizedClientReactiveOAuth2AuthorizationSuccessHandler;
3937
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
4038
import org.springframework.security.oauth2.client.endpoint.OAuth2ClientCredentialsGrantRequest;
4139
import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient;
4240
import org.springframework.security.oauth2.client.registration.ClientRegistration;
4341
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
4442
import org.springframework.security.oauth2.client.web.DefaultReactiveOAuth2AuthorizedClientManager;
43+
import org.springframework.security.oauth2.client.web.RemoveAuthorizedClientReactiveOAuth2AuthorizationFailureHandler;
44+
import org.springframework.security.oauth2.client.web.SaveAuthorizedClientReactiveOAuth2AuthorizationSuccessHandler;
4545
import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository;
4646
import org.springframework.security.oauth2.client.web.server.UnAuthenticatedServerOAuth2AuthorizedClientRepository;
4747
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
4848
import org.springframework.security.oauth2.core.OAuth2Error;
4949
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
50+
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
5051
import org.springframework.util.Assert;
52+
import org.springframework.util.StringUtils;
5153
import org.springframework.web.reactive.function.client.ClientRequest;
5254
import org.springframework.web.reactive.function.client.ClientResponse;
5355
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
@@ -62,6 +64,8 @@
6264
import java.util.Map;
6365
import java.util.Optional;
6466
import java.util.function.Consumer;
67+
import java.util.stream.Collectors;
68+
import java.util.stream.Stream;
6569

6670
/**
6771
* Provides an easy mechanism for using an {@link OAuth2AuthorizedClient} to make OAuth2 requests by including the
@@ -614,32 +618,84 @@ private AuthorizationFailureForwarder(ReactiveOAuth2AuthorizationFailureHandler
614618
}
615619

616620
@Override
617-
public Mono<ClientResponse> handleResponse(
618-
ClientRequest request,
619-
Mono<ClientResponse> responseMono) {
620-
621+
public Mono<ClientResponse> handleResponse(ClientRequest request, Mono<ClientResponse> responseMono) {
621622
return responseMono
622-
.flatMap(response -> handleHttpStatus(request, response.rawStatusCode(), null)
623+
.flatMap(response -> handleResponse(request, response)
623624
.thenReturn(response))
624-
.onErrorResume(WebClientResponseException.class, e -> handleHttpStatus(request, e.getRawStatusCode(), e)
625+
.onErrorResume(WebClientResponseException.class, e -> handleWebClientResponseException(request, e)
625626
.then(Mono.error(e)))
626627
.onErrorResume(OAuth2AuthorizationException.class, e -> handleAuthorizationException(request, e)
627628
.then(Mono.error(e)));
628629
}
629630

631+
private Mono<Void> handleResponse(ClientRequest request, ClientResponse response) {
632+
return Mono.justOrEmpty(resolveErrorIfPossible(response))
633+
.flatMap(oauth2Error -> {
634+
Mono<Optional<ServerWebExchange>> serverWebExchange = effectiveServerWebExchange(request);
635+
636+
Mono<String> clientRegistrationId = effectiveClientRegistrationId(request);
637+
638+
return Mono.zip(currentAuthenticationMono, serverWebExchange, clientRegistrationId)
639+
.flatMap(tuple3 -> handleAuthorizationFailure(
640+
tuple3.getT1(), // Authentication principal
641+
tuple3.getT2().orElse(null), // ServerWebExchange exchange
642+
new ClientAuthorizationException(
643+
oauth2Error,
644+
tuple3.getT3()))); // String clientRegistrationId
645+
});
646+
}
647+
648+
private OAuth2Error resolveErrorIfPossible(ClientResponse response) {
649+
// Try to resolve from 'WWW-Authenticate' header
650+
if (!response.headers().header(HttpHeaders.WWW_AUTHENTICATE).isEmpty()) {
651+
String wwwAuthenticateHeader = response.headers().header(HttpHeaders.WWW_AUTHENTICATE).get(0);
652+
Map<String, String> authParameters = parseAuthParameters(wwwAuthenticateHeader);
653+
if (authParameters.containsKey(OAuth2ParameterNames.ERROR)) {
654+
return new OAuth2Error(
655+
authParameters.get(OAuth2ParameterNames.ERROR),
656+
authParameters.get(OAuth2ParameterNames.ERROR_DESCRIPTION),
657+
authParameters.get(OAuth2ParameterNames.ERROR_URI));
658+
}
659+
}
660+
return resolveErrorIfPossible(response.rawStatusCode());
661+
}
662+
663+
private OAuth2Error resolveErrorIfPossible(int statusCode) {
664+
if (this.httpStatusToOAuth2ErrorCodeMap.containsKey(statusCode)) {
665+
return new OAuth2Error(
666+
this.httpStatusToOAuth2ErrorCodeMap.get(statusCode),
667+
null,
668+
"https://tools.ietf.org/html/rfc6750#section-3.1");
669+
}
670+
return null;
671+
}
672+
673+
private Map<String, String> parseAuthParameters(String wwwAuthenticateHeader) {
674+
return Stream.of(wwwAuthenticateHeader)
675+
.filter(header -> !StringUtils.isEmpty(header))
676+
.filter(header -> header.toLowerCase().startsWith("bearer"))
677+
.map(header -> header.substring("bearer".length()))
678+
.map(header -> header.split(","))
679+
.flatMap(Stream::of)
680+
.map(parameter -> parameter.split("="))
681+
.filter(parameter -> parameter.length > 1)
682+
.collect(Collectors.toMap(
683+
parameters -> parameters[0].trim(),
684+
parameters -> parameters[1].trim().replace("\"", "")));
685+
}
686+
630687
/**
631688
* Handles the given http status code returned from a resource server
632689
* by notifying the authorization failure handler if the http status
633690
* code is in the {@link #httpStatusToOAuth2ErrorCodeMap}.
634691
*
635692
* @param request the request being processed
636-
* @param httpStatusCode the http status returned by the resource server
637-
* @param exception The root cause exception for the failure (nullable)
693+
* @param exception The root cause exception for the failure
638694
* @return a {@link Mono} that completes empty after the authorization failure handler completes.
639695
*/
640-
private Mono<Void> handleHttpStatus(ClientRequest request, int httpStatusCode, @Nullable Exception exception) {
641-
return Mono.justOrEmpty(this.httpStatusToOAuth2ErrorCodeMap.get(httpStatusCode))
642-
.flatMap(oauth2ErrorCode -> {
696+
private Mono<Void> handleWebClientResponseException(ClientRequest request, WebClientResponseException exception) {
697+
return Mono.justOrEmpty(resolveErrorIfPossible(exception.getRawStatusCode()))
698+
.flatMap(oauth2Error -> {
643699
Mono<Optional<ServerWebExchange>> serverWebExchange = effectiveServerWebExchange(request);
644700

645701
Mono<String> clientRegistrationId = effectiveClientRegistrationId(request);
@@ -648,9 +704,9 @@ private Mono<Void> handleHttpStatus(ClientRequest request, int httpStatusCode, @
648704
.flatMap(tuple3 -> handleAuthorizationFailure(
649705
tuple3.getT1(), // Authentication principal
650706
tuple3.getT2().orElse(null), // ServerWebExchange exchange
651-
createAuthorizationException(
707+
new ClientAuthorizationException(
708+
oauth2Error,
652709
tuple3.getT3(), // String clientRegistrationId
653-
oauth2ErrorCode,
654710
exception)));
655711
});
656712
}
@@ -673,28 +729,6 @@ private Mono<Void> handleAuthorizationException(ClientRequest request, OAuth2Aut
673729
exception));
674730
}
675731

676-
/**
677-
* Creates an authorization exception using the given parameters.
678-
*
679-
* @param clientRegistrationId the client registration id of the client that failed authentication/authorization.
680-
* @param oauth2ErrorCode the OAuth 2.0 error code to use in the authorization failure event
681-
* @param exception The root cause exception for the failure (nullable)
682-
* @return an authorization exception using the given parameters.
683-
*/
684-
private ClientAuthorizationException createAuthorizationException(
685-
String clientRegistrationId,
686-
String oauth2ErrorCode,
687-
@Nullable Exception exception) {
688-
return new ClientAuthorizationException(
689-
new OAuth2Error(
690-
oauth2ErrorCode,
691-
null,
692-
"https://tools.ietf.org/html/rfc6750#section-3.1"),
693-
clientRegistrationId,
694-
exception);
695-
}
696-
697-
698732
/**
699733
* Delegates to the authorization failure handler of the failed authorization.
700734
*

oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/web/reactive/function/client/ServerOAuth2AuthorizedClientExchangeFilterFunctionTests.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
import org.springframework.util.StringUtils;
7575
import org.springframework.web.reactive.function.BodyInserter;
7676
import org.springframework.web.reactive.function.client.ClientRequest;
77+
import org.springframework.web.reactive.function.client.ClientResponse;
7778
import org.springframework.web.reactive.function.client.ExchangeFunction;
7879
import org.springframework.web.reactive.function.client.WebClientResponseException;
7980
import org.springframework.web.server.ServerWebExchange;
@@ -98,6 +99,7 @@
9899
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
99100
import static org.mockito.Mockito.any;
100101
import static org.mockito.Mockito.eq;
102+
import static org.mockito.Mockito.mock;
101103
import static org.mockito.Mockito.never;
102104
import static org.mockito.Mockito.spy;
103105
import static org.mockito.Mockito.verify;
@@ -173,6 +175,7 @@ public void setup() {
173175
this.authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
174176
this.function = new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
175177
when(this.authorizedClientRepository.saveAuthorizedClient(any(), any(), any())).thenReturn(Mono.empty());
178+
when(this.exchange.getResponse().headers()).thenReturn(mock(ClientResponse.Headers.class));
176179
}
177180

178181
@Test
@@ -621,6 +624,54 @@ public void filterWhenForbiddenWithWebClientExceptionThenInvokeFailureHandler()
621624
.containsExactly(entry(ServerWebExchange.class.getName(), this.serverWebExchange));
622625
}
623626

627+
@Test
628+
public void filterWhenWWWAuthenticateHeaderIncludesErrorThenInvokeFailureHandler() {
629+
function.setAuthorizationFailureHandler(authorizationFailureHandler);
630+
631+
PublisherProbe<Void> publisherProbe = PublisherProbe.empty();
632+
when(authorizationFailureHandler.onAuthorizationFailure(any(), any(), any())).thenReturn(publisherProbe.mono());
633+
634+
OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", this.accessToken.getIssuedAt());
635+
OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(this.registration,
636+
"principalName", this.accessToken, refreshToken);
637+
ClientRequest request = ClientRequest.create(GET, URI.create("https://example.com"))
638+
.attributes(oauth2AuthorizedClient(authorizedClient))
639+
.build();
640+
641+
String wwwAuthenticateHeader = "Bearer error=\"insufficient_scope\", " +
642+
"error_description=\"The request requires higher privileges than provided by the access token.\", " +
643+
"error_uri=\"https://tools.ietf.org/html/rfc6750#section-3.1\"";
644+
ClientResponse.Headers headers = mock(ClientResponse.Headers.class);
645+
when(headers.header(eq(HttpHeaders.WWW_AUTHENTICATE)))
646+
.thenReturn(Collections.singletonList(wwwAuthenticateHeader));
647+
when(this.exchange.getResponse().headers()).thenReturn(headers);
648+
649+
this.function.filter(request, this.exchange)
650+
.subscriberContext(serverWebExchange())
651+
.block();
652+
653+
assertThat(publisherProbe.wasSubscribed()).isTrue();
654+
655+
verify(authorizationFailureHandler).onAuthorizationFailure(
656+
authorizationExceptionCaptor.capture(),
657+
authenticationCaptor.capture(),
658+
attributesCaptor.capture());
659+
660+
assertThat(authorizationExceptionCaptor.getValue())
661+
.isInstanceOfSatisfying(ClientAuthorizationException.class, e -> {
662+
assertThat(e.getClientRegistrationId()).isEqualTo(registration.getRegistrationId());
663+
assertThat(e.getError().getErrorCode()).isEqualTo(OAuth2ErrorCodes.INSUFFICIENT_SCOPE);
664+
assertThat(e.getError().getDescription()).isEqualTo("The request requires higher privileges than provided by the access token.");
665+
assertThat(e.getError().getUri()).isEqualTo("https://tools.ietf.org/html/rfc6750#section-3.1");
666+
assertThat(e).hasNoCause();
667+
assertThat(e).hasMessageContaining(OAuth2ErrorCodes.INSUFFICIENT_SCOPE);
668+
});
669+
assertThat(authenticationCaptor.getValue())
670+
.isInstanceOf(AnonymousAuthenticationToken.class);
671+
assertThat(attributesCaptor.getValue())
672+
.containsExactly(entry(ServerWebExchange.class.getName(), this.serverWebExchange));
673+
}
674+
624675
@Test
625676
public void filterWhenAuthorizationExceptionThenInvokeFailureHandler() {
626677
function.setAuthorizationFailureHandler(authorizationFailureHandler);

0 commit comments

Comments
 (0)