Introduce Reactive OAuth2Authorization success/failure handlers

All ReactiveOAuth2AuthorizedClientManagers now have authorization success/failure handlers.
A success handler is provided to save authorized clients for future requests.
A failure handler is provided to remove previously saved authorized clients.

ServerOAuth2AuthorizedClientExchangeFilterFunction also makes use of a
failure handler in the case of unauthorized or forbidden http status code.

The main use cases now handled are
- remove authorized client when an authorization server indicates that a refresh token is no longer valid (when authorization server returns invalid_grant)
- remove authorized client when a resource server indicates that an access token is no longer valid (when resource server returns invalid_token)

Introduced ClientAuthorizationException to capture details needed when removing an authorized client.
All ReactiveOAuth2AccessTokenResponseClients now throw a ClientAuthorizationException on failures.

Created AbstractWebClientReactiveOAuth2AccessTokenResponseClient to unify common logic between all ReactiveOAuth2AccessTokenResponseClients.

Fixes gh-7699
This commit is contained in:
Phil Clay
2019-12-16 20:19:22 -08:00
committed by Joe Grandja
parent 7f9715d951
commit e5fca61810
26 changed files with 2506 additions and 482 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2018 the original author or authors.
* Copyright 2002-2020 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.
@@ -15,6 +15,8 @@
*/
package org.springframework.security.oauth2.core;
import org.springframework.util.Assert;
/**
* Base exception for OAuth 2.0 Authorization errors.
*
@@ -30,7 +32,19 @@ public class OAuth2AuthorizationException extends RuntimeException {
* @param error the {@link OAuth2Error OAuth 2.0 Error}
*/
public OAuth2AuthorizationException(OAuth2Error error) {
super(error.toString());
this(error, error.toString());
}
/**
* Constructs an {@code OAuth2AuthorizationException} using the provided parameters.
*
* @param error the {@link OAuth2Error OAuth 2.0 Error}
* @param message the exception message
* @since 5.3
*/
public OAuth2AuthorizationException(OAuth2Error error, String message) {
super(message);
Assert.notNull(error, "error must not be null");
this.error = error;
}
@@ -41,7 +55,20 @@ public class OAuth2AuthorizationException extends RuntimeException {
* @param cause the root cause
*/
public OAuth2AuthorizationException(OAuth2Error error, Throwable cause) {
super(error.toString(), cause);
this(error, error.toString(), cause);
}
/**
* Constructs an {@code OAuth2AuthorizationException} using the provided parameters.
*
* @param error the {@link OAuth2Error OAuth 2.0 Error}
* @param message the exception message
* @param cause the root cause
* @since 5.3
*/
public OAuth2AuthorizationException(OAuth2Error error, String message, Throwable cause) {
super(message, cause);
Assert.notNull(error, "error must not be null");
this.error = error;
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2017 the original author or authors.
* Copyright 2002-2020 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.
@@ -53,6 +53,27 @@ public interface OAuth2ErrorCodes {
*/
String INVALID_SCOPE = "invalid_scope";
/**
* {@code insufficient_scope} - The request requires higher privileges than
* provided by the access token.
* The resource server SHOULD respond with the HTTP 403 (Forbidden)
* status code and MAY include the "scope" attribute with the scope
* necessary to access the protected resource.
*
* @see <a href="https://tools.ietf.org/html/rfc6750#section-3.1">RFC-6750 - Section 3.1 - Error Codes</a>
*/
String INSUFFICIENT_SCOPE = "insufficient_scope";
/**
* {@code invalid_token} - The access token provided is expired, revoked,
* malformed, or invalid for other reasons.
* The resource SHOULD respond with the HTTP 401 (Unauthorized) status code.
* The client MAY request a new access token and retry the protected resource request.
*
* @see <a href="https://tools.ietf.org/html/rfc6750#section-3.1">RFC-6750 - Section 3.1 - Error Codes</a>
*/
String INVALID_TOKEN = "invalid_token";
/**
* {@code server_error} - The authorization server encountered an
* unexpected condition that prevented it from fulfilling the request.

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2018 the original author or authors.
* Copyright 2002-2020 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.
@@ -58,6 +58,10 @@ class OAuth2AccessTokenResponseBodyExtractor
ParameterizedTypeReference<Map<String, Object>> type = new ParameterizedTypeReference<Map<String, Object>>() {};
BodyExtractor<Mono<Map<String, Object>>, ReactiveHttpInputMessage> delegate = BodyExtractors.toMono(type);
return delegate.extract(inputMessage, context)
.onErrorMap(e -> new OAuth2AuthorizationException(
invalidTokenResponse("An error occurred parsing the Access Token response: " + e.getMessage()), e))
.switchIfEmpty(Mono.error(() -> new OAuth2AuthorizationException(
invalidTokenResponse("Empty OAuth 2.0 Access Token Response"))))
.map(OAuth2AccessTokenResponseBodyExtractor::parse)
.flatMap(OAuth2AccessTokenResponseBodyExtractor::oauth2AccessTokenResponse)
.map(OAuth2AccessTokenResponseBodyExtractor::oauth2AccessTokenResponse);
@@ -68,12 +72,19 @@ class OAuth2AccessTokenResponseBodyExtractor
return TokenResponse.parse(new JSONObject(json));
}
catch (ParseException pe) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_TOKEN_RESPONSE_ERROR_CODE,
"An error occurred parsing the Access Token response: " + pe.getMessage(), null);
OAuth2Error oauth2Error = invalidTokenResponse(
"An error occurred parsing the Access Token response: " + pe.getMessage());
throw new OAuth2AuthorizationException(oauth2Error, pe);
}
}
private static OAuth2Error invalidTokenResponse(String message) {
return new OAuth2Error(
INVALID_TOKEN_RESPONSE_ERROR_CODE,
message,
null);
}
private static Mono<AccessTokenResponse> oauth2AccessTokenResponse(TokenResponse tokenResponse) {
if (tokenResponse.indicatesSuccess()) {
return Mono.just(tokenResponse)

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2018 the original author or authors.
* Copyright 2002-2020 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.
@@ -30,6 +30,7 @@ import org.springframework.http.codec.json.Jackson2JsonDecoder;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.mock.http.client.reactive.MockClientHttpResponse;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.web.reactive.function.BodyExtractor;
import reactor.core.publisher.Mono;
@@ -92,8 +93,23 @@ public class OAuth2BodyExtractorsTests {
Mono<OAuth2AccessTokenResponse> result = extractor.extract(response, this.context);
assertThatCode(() -> result.block())
.isInstanceOf(RuntimeException.class);
assertThatCode(result::block)
.isInstanceOf(OAuth2AuthorizationException.class)
.hasMessageContaining("An error occurred parsing the Access Token response");
}
@Test
public void oauth2AccessTokenResponseWhenEmptyThenException() {
BodyExtractor<Mono<OAuth2AccessTokenResponse>, ReactiveHttpInputMessage> extractor = OAuth2BodyExtractors
.oauth2AccessTokenResponse();
MockClientHttpResponse response = new MockClientHttpResponse(HttpStatus.OK);
Mono<OAuth2AccessTokenResponse> result = extractor.extract(response, this.context);
assertThatCode(result::block)
.isInstanceOf(OAuth2AuthorizationException.class)
.hasMessageContaining("Empty OAuth 2.0 Access Token Response");
}
@Test